Merge pull request #1441 from ThibG/glitch-soc/merge-upstream

Merge upstream changes
th-downstream
ThibG 4 years ago committed by GitHub
commit b2b0cef2a4

@ -30,7 +30,7 @@ plugins:
channel: eslint-7 channel: eslint-7
rubocop: rubocop:
enabled: true enabled: true
channel: rubocop-0-88 channel: rubocop-0-92
sass-lint: sass-lint:
enabled: true enabled: true
exclude_patterns: exclude_patterns:

@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.7' gem 'pghero', '~> 2.7'
gem 'dotenv-rails', '~> 2.7' gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.81', require: false gem 'aws-sdk-s3', '~> 1.83', require: false
gem 'fog-core', '<= 2.1.0' gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0' gem 'paperclip', '~> 6.0'
@ -54,7 +54,6 @@ gem 'doorkeeper', '~> 5.4'
gem 'ed25519', '~> 1.2' gem 'ed25519', '~> 1.2'
gem 'fast_blank', '~> 1.0' gem 'fast_blank', '~> 1.0'
gem 'fastimage' gem 'fastimage'
gem 'goldfinger', '~> 2.1'
gem 'hiredis', '~> 0.6' gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.8' gem 'redis-namespace', '~> 1.8'
gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b' gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b'
@ -142,9 +141,9 @@ group :development do
gem 'letter_opener', '~> 1.7' gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.4' gem 'letter_opener_web', '~> 1.4'
gem 'memory_profiler' gem 'memory_profiler'
gem 'rubocop', '~> 0.91', require: false gem 'rubocop', '~> 0.93', require: false
gem 'rubocop-rails', '~> 2.8', require: false gem 'rubocop-rails', '~> 2.8', require: false
gem 'brakeman', '~> 4.9', require: false gem 'brakeman', '~> 4.10', require: false
gem 'bundler-audit', '~> 0.7', require: false gem 'bundler-audit', '~> 0.7', require: false
gem 'capistrano', '~> 3.14' gem 'capistrano', '~> 3.14'
@ -162,3 +161,6 @@ end
gem 'concurrent-ruby', require: false gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1'
gem 'pluck_each', '~> 0.1.3'

@ -79,23 +79,23 @@ GEM
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
awrence (1.1.1) awrence (1.1.1)
aws-eventstream (1.1.0) aws-eventstream (1.1.0)
aws-partitions (1.373.0) aws-partitions (1.380.0)
aws-sdk-core (3.107.0) aws-sdk-core (3.109.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0) aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-kms (1.38.0) aws-sdk-kms (1.39.0)
aws-sdk-core (~> 3, >= 3.99.0) aws-sdk-core (~> 3, >= 3.109.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.81.0) aws-sdk-s3 (1.83.0)
aws-sdk-core (~> 3, >= 3.104.3) aws-sdk-core (~> 3, >= 3.109.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.2) aws-sigv4 (1.2.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
bcrypt (3.1.16) bcrypt (3.1.16)
better_errors (2.8.1) better_errors (2.8.3)
coderay (>= 1.0.0) coderay (>= 1.0.0)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
@ -106,7 +106,7 @@ GEM
ffi (~> 1.10.0) ffi (~> 1.10.0)
bootsnap (1.4.8) bootsnap (1.4.8)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.9.1) brakeman (4.10.0)
browser (4.2.0) browser (4.2.0)
builder (3.2.4) builder (3.2.4)
bullet (6.1.0) bullet (6.1.0)
@ -240,12 +240,7 @@ GEM
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
globalid (0.4.2) globalid (0.4.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
goldfinger (2.1.1) hamlit (2.13.0)
addressable (~> 2.5)
http (~> 4.0)
nokogiri (~> 1.8)
oj (~> 3.0)
hamlit (2.11.1)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
tilt tilt
@ -399,7 +394,7 @@ GEM
parallel (1.19.2) parallel (1.19.2)
parallel_tests (3.3.0) parallel_tests (3.3.0)
parallel parallel
parser (2.7.1.4) parser (2.7.2.0)
ast (~> 2.4.1) ast (~> 2.4.1)
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
@ -407,7 +402,10 @@ GEM
pg (1.2.3) pg (1.2.3)
pghero (2.7.2) pghero (2.7.2)
activerecord (>= 5) activerecord (>= 5)
pkg-config (1.4.3) pkg-config (1.4.4)
pluck_each (0.1.3)
activerecord (> 3.2.0)
activesupport (> 3.0.0)
posix-spawn (0.3.15) posix-spawn (0.3.15)
premailer (1.14.2) premailer (1.14.2)
addressable addressable
@ -426,11 +424,11 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.6)
puma (5.0.1) puma (5.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.1.0) pundit (2.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.3.1) raabro (1.3.3)
rack (2.2.3) rack (2.2.3)
rack-attack (6.3.1) rack-attack (6.3.1)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
@ -500,7 +498,7 @@ GEM
redis-store (>= 1.2, < 2) redis-store (>= 1.2, < 2)
redis-store (1.9.0) redis-store (1.9.0)
redis (>= 4, < 5) redis (>= 4, < 5)
regexp_parser (1.8.0) regexp_parser (1.8.2)
request_store (1.5.0) request_store (1.5.0)
rack (>= 1.4) rack (>= 1.4)
responders (3.0.1) responders (3.0.1)
@ -513,7 +511,7 @@ GEM
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 0.1) rqrcode_core (~> 0.1)
rqrcode_core (0.1.2) rqrcode_core (0.1.2)
rspec-core (3.9.2) rspec-core (3.9.3)
rspec-support (~> 3.9.3) rspec-support (~> 3.9.3)
rspec-expectations (3.9.2) rspec-expectations (3.9.2)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
@ -535,17 +533,17 @@ GEM
rspec-support (3.9.3) rspec-support (3.9.3)
rspec_junit_formatter (0.4.1) rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (0.91.0) rubocop (0.93.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.7.1.1) parser (>= 2.7.1.5)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.7) regexp_parser (>= 1.8)
rexml rexml
rubocop-ast (>= 0.4.0, < 1.0) rubocop-ast (>= 0.6.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 2.0) unicode-display_width (>= 1.4.0, < 2.0)
rubocop-ast (0.4.2) rubocop-ast (0.7.1)
parser (>= 2.7.1.4) parser (>= 2.7.1.5)
rubocop-rails (2.8.1) rubocop-rails (2.8.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
@ -576,19 +574,19 @@ GEM
sidekiq (>= 3) sidekiq (>= 3)
thwait thwait
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (6.0.23) sidekiq-unique-jobs (6.0.24)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 7.0) sidekiq (>= 4.0, < 7.0)
thor (>= 0.20, < 2.0) thor (>= 0.20, < 2.0)
simple-navigation (4.1.0) simple-navigation (4.1.0)
activesupport (>= 2.3.2) activesupport (>= 2.3.2)
simple_form (5.0.2) simple_form (5.0.3)
actionpack (>= 5.0) actionpack (>= 5.0)
activemodel (>= 5.0) activemodel (>= 5.0)
simplecov (0.19.0) simplecov (0.19.0)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov-html (0.12.2) simplecov-html (0.12.3)
sprockets (3.7.2) sprockets (3.7.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
@ -633,7 +631,7 @@ GEM
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (1.2.7) tzinfo (1.2.7)
thread_safe (~> 0.1) thread_safe (~> 0.1)
tzinfo-data (1.2020.1) tzinfo-data (1.2020.2)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
@ -668,6 +666,7 @@ GEM
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.1) wisper (2.0.1)
xorcist (1.1.2)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
@ -679,12 +678,12 @@ DEPENDENCIES
active_record_query_trace (~> 1.7) active_record_query_trace (~> 1.7)
addressable (~> 2.7) addressable (~> 2.7)
annotate (~> 3.1) annotate (~> 3.1)
aws-sdk-s3 (~> 1.81) aws-sdk-s3 (~> 1.83)
better_errors (~> 2.8) better_errors (~> 2.8)
binding_of_caller (~> 0.7) binding_of_caller (~> 0.7)
blurhash (~> 0.1) blurhash (~> 0.1)
bootsnap (~> 1.4) bootsnap (~> 1.4)
brakeman (~> 4.9) brakeman (~> 4.10)
browser browser
bullet (~> 6.1) bullet (~> 6.1)
bundler-audit (~> 0.7) bundler-audit (~> 0.7)
@ -715,7 +714,6 @@ DEPENDENCIES
fog-core (<= 2.1.0) fog-core (<= 2.1.0)
fog-openstack (~> 0.3) fog-openstack (~> 0.3)
fuubar (~> 2.5) fuubar (~> 2.5)
goldfinger (~> 2.1)
hamlit-rails (~> 0.2) hamlit-rails (~> 0.2)
health_check! health_check!
hiredis (~> 0.6) hiredis (~> 0.6)
@ -755,6 +753,7 @@ DEPENDENCIES
pg (~> 1.2) pg (~> 1.2)
pghero (~> 2.7) pghero (~> 2.7)
pkg-config (~> 1.4) pkg-config (~> 1.4)
pluck_each (~> 0.1.3)
posix-spawn posix-spawn
premailer-rails premailer-rails
private_address_check (~> 0.5) private_address_check (~> 0.5)
@ -778,7 +777,7 @@ DEPENDENCIES
rspec-rails (~> 4.0) rspec-rails (~> 4.0)
rspec-sidekiq (~> 3.1) rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.4) rspec_junit_formatter (~> 0.4)
rubocop (~> 0.91) rubocop (~> 0.93)
rubocop-rails (~> 2.8) rubocop-rails (~> 2.8)
ruby-progressbar (~> 1.10) ruby-progressbar (~> 1.10)
sanitize (~> 5.2) sanitize (~> 5.2)
@ -804,3 +803,4 @@ DEPENDENCIES
webmock (~> 3.9) webmock (~> 3.9)
webpacker (~> 5.2) webpacker (~> 5.2)
webpush webpush
xorcist (~> 1.1)

@ -0,0 +1,36 @@
# frozen_string_literal: true
class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController
include SignatureVerification
include AccountOwnedConcern
before_action :require_signature!
before_action :set_items
before_action :set_cache_headers
def show
expires_in 0, public: false
render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end
private
def uri_prefix
signed_request_account.uri[/http(s?):\/\/[^\/]+\//]
end
def set_items
@items = @account.followers.where(Account.arel_table[:uri].matches(uri_prefix + '%', false, true)).pluck(:uri)
end
def collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_followers_synchronization_url(@account),
type: :ordered,
items: @items
)
end
end

@ -11,6 +11,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
def create def create
upgrade_account upgrade_account
process_collection_synchronization
process_payload process_payload
head 202 head 202
end end
@ -52,6 +53,19 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
DeliveryFailureTracker.reset!(signed_request_account.inbox_url) DeliveryFailureTracker.reset!(signed_request_account.inbox_url)
end end
def process_collection_synchronization
raw_params = request.headers['Collection-Synchronization']
return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true'
# Re-using the syntax for signature parameters
tree = SignatureParamsParser.new.parse(raw_params)
params = SignatureParamsTransformer.new.apply(tree)
ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params)
rescue Parslet::ParseFailed
Rails.logger.warn 'Error parsing Collection-Synchronization header'
end
def process_payload def process_payload
ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id) ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id)
end end

@ -0,0 +1,56 @@
# frozen_string_literal: true
module Admin
class IpBlocksController < BaseController
def index
authorize :ip_block, :index?
@ip_blocks = IpBlock.page(params[:page])
@form = Form::IpBlockBatch.new
end
def new
authorize :ip_block, :create?
@ip_block = IpBlock.new(ip: '', severity: :no_access, expires_in: 1.year)
end
def create
authorize :ip_block, :create?
@ip_block = IpBlock.new(resource_params)
if @ip_block.save
log_action :create, @ip_block
redirect_to admin_ip_blocks_path, notice: I18n.t('admin.ip_blocks.created_msg')
else
render :new
end
end
def batch
@form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.ip_blocks.no_ip_block_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
ensure
redirect_to admin_ip_blocks_path
end
private
def resource_params
params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in)
end
def action_from_button
'delete' if params[:delete]
end
def form_ip_block_batch_params
params.require(:form_ip_block_batch).permit(ip_block_ids: [])
end
end
end

@ -20,7 +20,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def create def create
token = AppSignUpService.new.call(doorkeeper_token.application, account_params) token = AppSignUpService.new.call(doorkeeper_token.application, request.remote_ip, account_params)
response = Doorkeeper::OAuth::TokenResponse.new(token) response = Doorkeeper::OAuth::TokenResponse.new(token)
headers.merge!(response.headers) headers.merge!(response.headers)
@ -42,7 +42,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def mute def mute
MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications)) MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), duration: (params[:duration] || 0))
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end end

@ -6,13 +6,8 @@ class Api::V1::MutesController < Api::BaseController
after_action :insert_pagination_headers after_action :insert_pagination_headers
def index def index
@data = @accounts = load_accounts @accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer render json: @accounts, each_serializer: REST::MutedAccountSerializer
end
def details
@data = @mutes = load_mutes
render json: @mutes, each_serializer: REST::MuteSerializer
end end
private private
@ -21,10 +16,6 @@ class Api::V1::MutesController < Api::BaseController
paginated_mutes.map(&:target_account) paginated_mutes.map(&:target_account)
end end
def load_mutes
paginated_mutes.includes(:account, :target_account).to_a
end
def paginated_mutes def paginated_mutes
@paginated_mutes ||= Mute.eager_load(:target_account) @paginated_mutes ||= Mute.eager_load(:target_account)
.joins(:target_account) .joins(:target_account)
@ -43,34 +34,26 @@ class Api::V1::MutesController < Api::BaseController
def next_path def next_path
if records_continue? if records_continue?
url_for pagination_params(max_id: pagination_max_id) api_v1_mutes_url pagination_params(max_id: pagination_max_id)
end end
end end
def prev_path def prev_path
unless @data.empty? unless paginated_mutes.empty?
url_for pagination_params(since_id: pagination_since_id) api_v1_mutes_url pagination_params(since_id: pagination_since_id)
end end
end end
def pagination_max_id def pagination_max_id
if params[:action] == "details"
@mutes.last.id
else
paginated_mutes.last.id paginated_mutes.last.id
end end
end
def pagination_since_id def pagination_since_id
if params[:action] == "details"
@mutes.first.id
else
paginated_mutes.first.id paginated_mutes.first.id
end end
end
def records_continue? def records_continue?
@data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) paginated_mutes.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end end
def pagination_params(core_params) def pagination_params(core_params)

@ -48,7 +48,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
resource.locale = I18n.locale resource.locale = I18n.locale
resource.invite_code = params[:invite_code] if resource.invite_code.blank? resource.invite_code = params[:invite_code] if resource.invite_code.blank?
resource.current_sign_in_ip = request.remote_ip resource.sign_up_ip = request.remote_ip
resource.build_account if resource.account.nil? resource.build_account if resource.account.nil?
end end

@ -44,6 +44,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_display_media, :setting_display_media,
:setting_expand_spoilers, :setting_expand_spoilers,
:setting_reduce_motion, :setting_reduce_motion,
:setting_disable_swiping,
:setting_system_font_ui, :setting_system_font_ui,
:setting_system_emoji_font, :setting_system_emoji_font,
:setting_noindex, :setting_noindex,

@ -29,6 +29,8 @@ module Admin::ActionLogsHelper
link_to record.target_account.acct, admin_account_path(record.target_account_id) link_to record.target_account.acct, admin_account_path(record.target_account_id)
when 'Announcement' when 'Announcement'
link_to truncate(record.text), edit_admin_announcement_path(record.id) link_to truncate(record.text), edit_admin_announcement_path(record.id)
when 'IpBlock'
"#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
end end
end end
@ -48,6 +50,8 @@ module Admin::ActionLogsHelper
end end
when 'Announcement' when 'Announcement'
truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text']) truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
when 'IpBlock'
"#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})"
end end
end end
end end

@ -1,38 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
# Monkey-patch on monkey-patch.
# Because it conflicts with the request.rb patch.
class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation
def connect(socket_class, host, port, nodelay = false)
::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do
@socket = socket_class.open(host, port)
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
end
end
end
module WebfingerHelper module WebfingerHelper
def webfinger!(uri) def webfinger!(uri)
hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri) Webfinger.new(uri).perform
raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri
opts = {
ssl: !hidden_service_uri,
headers: {
'User-Agent': Mastodon::Version.user_agent,
},
timeout_class: HTTP::Timeout::PerOperationOriginal,
timeout_options: {
write_timeout: 10,
connect_timeout: 5,
read_timeout: 10,
},
}
Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger
end end
end end

@ -1,5 +1,6 @@
// This file will be loaded on admin pages, regardless of theme. // This file will be loaded on admin pages, regardless of theme.
import 'packs/public-path';
import { delegate } from '@rails/ujs'; import { delegate } from '@rails/ujs';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';

@ -1,2 +1,3 @@
import 'packs/public-path';
import './settings'; import './settings';
import './two_factor_authentication'; import './two_factor_authentication';

@ -1,5 +1,6 @@
// This file will be loaded on all pages, regardless of theme. // This file will be loaded on all pages, regardless of theme.
import 'packs/public-path';
import 'font-awesome/css/font-awesome.css'; import 'font-awesome/css/font-awesome.css';
require.context('../images/', true); require.context('../images/', true);

@ -1,5 +1,7 @@
// This file will be loaded on embed pages, regardless of theme. // This file will be loaded on embed pages, regardless of theme.
import 'packs/public-path';
window.addEventListener('message', e => { window.addEventListener('message', e => {
const data = e.data || {}; const data = e.data || {};

@ -1,5 +1,6 @@
// This file will be loaded on public pages, regardless of theme. // This file will be loaded on public pages, regardless of theme.
import 'packs/public-path';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
const { delegate } = require('@rails/ujs'); const { delegate } = require('@rails/ujs');

@ -1,5 +1,6 @@
// This file will be loaded on settings pages, regardless of theme. // This file will be loaded on settings pages, regardless of theme.
import 'packs/public-path';
import escapeTextContentForBrowser from 'escape-html'; import escapeTextContentForBrowser from 'escape-html';
const { delegate } = require('@rails/ujs'); const { delegate } = require('@rails/ujs');
import emojify from '../mastodon/features/emoji/emoji'; import emojify from '../mastodon/features/emoji/emoji';

@ -1,3 +1,4 @@
import 'packs/public-path';
import axios from 'axios'; import axios from 'axios';
import * as WebAuthnJSON from '@github/webauthn-json'; import * as WebAuthnJSON from '@github/webauthn-json';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';

@ -274,11 +274,11 @@ export function unblockAccountFail(error) {
}; };
export function muteAccount(id, notifications) { export function muteAccount(id, notifications, duration=0) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(muteAccountRequest(id)); dispatch(muteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
}).catch(error => { }).catch(error => {

@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) {
}; };
}; };
export function submitMarkers() { export function submitMarkers(params = {}) {
return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
if (params.immediate === true) {
debouncedSubmitMarkers.flush();
}
return result;
}; };
export const fetchMarkers = () => (dispatch, getState) => { export const fetchMarkers = () => (dispatch, getState) => {

@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
export function fetchMutes() { export function fetchMutes() {
return (dispatch, getState) => { return (dispatch, getState) => {
@ -104,3 +105,12 @@ export function toggleHideNotifications() {
dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
}; };
} }
export function changeMuteDuration(duration) {
return dispatch => {
dispatch({
type: MUTES_CHANGE_DURATION,
duration,
});
};
}

@ -16,6 +16,7 @@ import { getFiltersRegex } from 'flavours/glitch/selectors';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
import compareId from 'flavours/glitch/util/compare_id'; import compareId from 'flavours/glitch/util/compare_id';
import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
import { requestNotificationPermission } from 'flavours/glitch/util/notifications';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@ -46,8 +47,12 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY'; export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY';
export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; 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_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
defineMessages({ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
}); });
@ -327,3 +332,42 @@ export function markNotificationsAsRead() {
type: NOTIFICATIONS_MARK_AS_READ, type: NOTIFICATIONS_MARK_AS_READ,
}; };
}; };
// Browser support
export function setupBrowserNotifications() {
return dispatch => {
dispatch(setBrowserSupport('Notification' in window));
if ('Notification' in window) {
dispatch(setBrowserPermission(Notification.permission));
}
if ('Notification' in window && 'permissions' in navigator) {
navigator.permissions.query({ name: 'notifications' }).then((status) => {
status.onchange = () => dispatch(setBrowserPermission(Notification.permission));
});
}
};
}
export function requestBrowserPermission(callback = noOp) {
return dispatch => {
requestNotificationPermission((permission) => {
dispatch(setBrowserPermission(permission));
callback(permission);
});
};
};
export function setBrowserSupport (value) {
return {
type: NOTIFICATIONS_SET_BROWSER_SUPPORT,
value,
};
}
export function setBrowserPermission (value) {
return {
type: NOTIFICATIONS_SET_BROWSER_PERMISSION,
value,
};
}

@ -8,6 +8,7 @@ import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from 'flavours/glitch/util/initial_state'; import { me } from 'flavours/glitch/util/initial_state';
import RelativeTimestamp from './relative_timestamp';
const messages = defineMessages({ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
@ -116,6 +117,11 @@ class Account extends ImmutablePureComponent {
} }
} }
let mute_expires_at;
if (account.get('mute_expires_at')) {
mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
}
return small ? ( return small ? (
<Permalink <Permalink
className='account small' className='account small'
@ -138,6 +144,7 @@ class Account extends ImmutablePureComponent {
<div className='account__wrapper'> <div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
{mute_expires_at}
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>
{buttons ? {buttons ?

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
const assetHost = process.env.CDN_HOST || ''; import { assetHost } from 'flavours/glitch/util/config';
export default class AutosuggestEmoji extends React.PureComponent { export default class AutosuggestEmoji extends React.PureComponent {

@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent {
onMove: PropTypes.func, onMove: PropTypes.func,
onClick: PropTypes.func, onClick: PropTypes.func,
appendContent: PropTypes.node, appendContent: PropTypes.node,
collapseIssues: PropTypes.bool,
}; };
state = { state = {
@ -88,7 +89,7 @@ class ColumnHeader extends React.PureComponent {
} }
render () { render () {
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
const { collapsed, animating } = this.state; const { collapsed, animating } = this.state;
const wrapperClassName = classNames('column-header__wrapper', { const wrapperClassName = classNames('column-header__wrapper', {
@ -150,7 +151,20 @@ class ColumnHeader extends React.PureComponent {
} }
if (children || (multiColumn && this.props.onPin)) { if (children || (multiColumn && this.props.onPin)) {
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>; collapseButton = (
<button
className={collapsibleButtonClassName}
title={formatMessage(collapsed ? messages.show : messages.hide)}
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
aria-pressed={collapsed ? 'false' : 'true'}
onClick={this.handleToggleClick}
>
<i className='icon-with-badge'>
<Icon id='sliders' />
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i>
</button>
);
} }
const hasTitle = icon && title; const hasTitle = icon && title;

@ -4,16 +4,18 @@ import Icon from 'flavours/glitch/components/icon';
const formatNumber = num => num > 40 ? '40+' : num; const formatNumber = num => num > 40 ? '40+' : num;
const IconWithBadge = ({ id, count, className }) => ( const IconWithBadge = ({ id, count, issueBadge, className }) => (
<i className='icon-with-badge'> <i className='icon-with-badge'>
<Icon id={id} fixedWidth className={className} /> <Icon id={id} fixedWidth className={className} />
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
{issueBadge && <i className='icon-with-badge__issue-badge' />}
</i> </i>
); );
IconWithBadge.propTypes = { IconWithBadge.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
count: PropTypes.number.isRequired, count: PropTypes.number.isRequired,
issueBadge: PropTypes.bool,
className: PropTypes.string, className: PropTypes.string,
}; };

@ -32,13 +32,6 @@ export default class Mastodon extends React.PureComponent {
componentDidMount() { componentDidMount() {
this.disconnect = store.dispatch(connectUserStream()); this.disconnect = store.dispatch(connectUserStream());
// Desktop notifications
// Ask after 1 minute
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
}
store.dispatch(showOnboardingOnce()); store.dispatch(showOnboardingOnce());
} }

@ -13,6 +13,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji'; import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji';
import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state'; import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state';
import { assetHost } from 'flavours/glitch/util/config';
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@ -105,7 +106,6 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
}, },
}); });
const assetHost = process.env.CDN_HOST || '';
let EmojiPicker, Emoji; // load asynchronously let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;

@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker';
import AnimatedNumber from 'flavours/glitch/components/animated_number'; import AnimatedNumber from 'flavours/glitch/components/animated_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion'; import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { assetHost } from 'flavours/glitch/util/config';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent {
} }
const assetHost = process.env.CDN_HOST || '';
class Emoji extends React.PureComponent { class Emoji extends React.PureComponent {
static propTypes = { static propTypes = {

@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent {
pushSettings: ImmutablePropTypes.map.isRequired, pushSettings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired,
onRequestNotificationPermission: PropTypes.func,
alertsEnabled: PropTypes.bool,
browserSupport: PropTypes.bool,
browserPermission: PropTypes.bool,
}; };
onPushChange = (path, checked) => { onPushChange = (path, checked) => {
@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent {
} }
render () { render () {
const { settings, pushSettings, onChange, onClear } = this.props; const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props;
const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
@ -33,6 +37,12 @@ export default class ColumnSettings extends React.PureComponent {
return ( return (
<div> <div>
{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>
</div>
)}
<div className='column-settings__row'> <div className='column-settings__row'>
<ClearColumnButton onClick={onClear} /> <ClearColumnButton onClick={onClear} />
</div> </div>
@ -41,6 +51,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-filter-bar' className='column-settings__section'> <span id='notifications-filter-bar' className='column-settings__section'>
<FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
</span> </span>
<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={filterShowStr} /> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
<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} />
@ -51,7 +62,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
@ -62,7 +73,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
@ -73,7 +84,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
@ -84,7 +95,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
@ -95,7 +106,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
@ -106,12 +117,23 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
<div role='group' aria-labelledby='notifications-status'>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
</div>
</div>
</div> </div>
); );
} }

@ -0,0 +1,30 @@
import React from 'react';
import Icon from 'flavours/glitch/components/icon';
import Button from 'flavours/glitch/components/button';
import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
export default @connect(() => {})
class NotificationsPermissionBanner extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
};
handleClick = () => {
this.props.dispatch(requestBrowserPermission());
}
render () {
return (
<div className='notifications-permission-banner'>
<h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
<p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p>
<Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
</div>
);
}
}

@ -13,6 +13,7 @@ export default class SettingToggle extends React.PureComponent {
meta: PropTypes.node, meta: PropTypes.node,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
defaultValue: PropTypes.bool, defaultValue: PropTypes.bool,
disabled: PropTypes.bool,
} }
onChange = ({ target }) => { onChange = ({ target }) => {
@ -20,12 +21,12 @@ export default class SettingToggle extends React.PureComponent {
} }
render () { render () {
const { prefix, settings, settingPath, label, meta, defaultValue } = this.props; const { prefix, settings, settingPath, label, meta, defaultValue, disabled } = this.props;
const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
return ( return (
<div className='setting-toggle'> <div className='setting-toggle'>
<Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
<label htmlFor={id} className='setting-toggle__label'>{label}</label> <label htmlFor={id} className='setting-toggle__label'>{label}</label>
{meta && <span className='setting-meta__label'>{meta}</span>} {meta && <span className='setting-meta__label'>{meta}</span>}
</div> </div>

@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl';
import ColumnSettings from '../components/column_settings'; import ColumnSettings from '../components/column_settings';
import { changeSetting } from 'flavours/glitch/actions/settings'; import { changeSetting } from 'flavours/glitch/actions/settings';
import { setFilter } from 'flavours/glitch/actions/notifications'; import { setFilter } from 'flavours/glitch/actions/notifications';
import { clearNotifications } from 'flavours/glitch/actions/notifications'; import { clearNotifications, requestBrowserPermission } from 'flavours/glitch/actions/notifications';
import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications'; import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { showAlert } from 'flavours/glitch/actions/alerts';
const messages = defineMessages({ const messages = defineMessages({
clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
settings: state.getIn(['settings', 'notifications']), settings: state.getIn(['settings', 'notifications']),
pushSettings: state.get('push_notifications'), pushSettings: state.get('push_notifications'),
alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
browserSupport: state.getIn(['notifications', 'browserSupport']),
browserPermission: state.getIn(['notifications', 'browserPermission']),
}); });
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onChange (path, checked) { onChange (path, checked) {
if (path[0] === 'push') { if (path[0] === 'push') {
if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
dispatch(requestBrowserPermission((permission) => {
if (permission === 'granted') {
dispatch(changePushNotifications(path.slice(1), checked)); dispatch(changePushNotifications(path.slice(1), checked));
} else {
dispatch(showAlert(undefined, messages.permissionDenied));
}
}));
} else {
dispatch(changePushNotifications(path.slice(1), checked));
}
} else if (path[0] === 'quickFilter') { } else if (path[0] === 'quickFilter') {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
dispatch(setFilter('all')); dispatch(setFilter('all'));
} else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
dispatch(requestBrowserPermission((permission) => {
if (permission === 'granted') {
dispatch(changeSetting(['notifications', ...path], checked));
} else {
dispatch(showAlert(undefined, messages.permissionDenied));
}
}));
} else {
dispatch(changeSetting(['notifications', ...path], checked));
}
} else { } else {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
} }
@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
})); }));
}, },
onRequestNotificationPermission () {
dispatch(requestBrowserPermission());
},
}); });
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));

@ -27,6 +27,7 @@ import ScrollableList from 'flavours/glitch/components/scrollable_list';
import LoadGap from 'flavours/glitch/components/load_gap'; import LoadGap from 'flavours/glitch/components/load_gap';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
import compareId from 'flavours/glitch/util/compare_id'; import compareId from 'flavours/glitch/util/compare_id';
import NotificationsPermissionBanner from './components/notifications_permission_banner';
import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container'; import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';
@ -62,6 +63,7 @@ const mapStateToProps = state => ({
notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
lastReadId: state.getIn(['notifications', 'readMarkerId']), lastReadId: state.getIn(['notifications', 'readMarkerId']),
canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default',
}); });
/* glitch */ /* glitch */
@ -71,7 +73,7 @@ const mapDispatchToProps = dispatch => ({
}, },
onMarkAsRead() { onMarkAsRead() {
dispatch(markNotificationsAsRead()); dispatch(markNotificationsAsRead());
dispatch(submitMarkers()); dispatch(submitMarkers({ immediate: true }));
}, },
onMount() { onMount() {
dispatch(mountNotifications()); dispatch(mountNotifications());
@ -105,6 +107,7 @@ class Notifications extends React.PureComponent {
onUnmount: PropTypes.func, onUnmount: PropTypes.func,
lastReadId: PropTypes.string, lastReadId: PropTypes.string,
canMarkAsRead: PropTypes.bool, canMarkAsRead: PropTypes.bool,
needsNotificationPermission: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -211,7 +214,7 @@ class Notifications extends React.PureComponent {
} }
render () { render () {
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props; const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
const { notifCleaning, notifCleaningActive } = this.props; const { notifCleaning, notifCleaningActive } = this.props;
const { animatingNCD } = this.state; const { animatingNCD } = this.state;
const pinned = !!columnId; const pinned = !!columnId;
@ -257,6 +260,8 @@ class Notifications extends React.PureComponent {
showLoading={isLoading && notifications.size === 0} showLoading={isLoading && notifications.size === 0}
hasMore={hasMore} hasMore={hasMore}
numPending={numPending} numPending={numPending}
prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
alwaysPrepend
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
onLoadMore={this.handleLoadOlder} onLoadMore={this.handleLoadOlder}
onLoadPending={this.handleLoadPending} onLoadPending={this.handleLoadPending}

@ -20,6 +20,7 @@ import GIFV from 'flavours/glitch/components/gifv';
import { me } from 'flavours/glitch/util/initial_state'; import { me } from 'flavours/glitch/util/initial_state';
import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
import { assetHost } from 'flavours/glitch/util/config';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
.replace(/\n/g, ' ') .replace(/\n/g, ' ')
.replace(/\*\*\*\*\*\*/g, '\n\n'); .replace(/\*\*\*\*\*\*/g, '\n\n');
const assetHost = process.env.CDN_HOST || '';
class ImageLoader extends React.PureComponent { class ImageLoader extends React.PureComponent {
static propTypes = { static propTypes = {

@ -1,25 +1,32 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle'; import Toggle from 'react-toggle';
import Button from 'flavours/glitch/components/button'; import Button from 'flavours/glitch/components/button';
import { closeModal } from 'flavours/glitch/actions/modal'; import { closeModal } from 'flavours/glitch/actions/modal';
import { muteAccount } from 'flavours/glitch/actions/accounts'; import { muteAccount } from 'flavours/glitch/actions/accounts';
import { toggleHideNotifications } from 'flavours/glitch/actions/mutes'; import { toggleHideNotifications, changeMuteDuration } from 'flavours/glitch/actions/mutes';
const messages = defineMessages({
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' },
});
const mapStateToProps = state => { const mapStateToProps = state => {
return { return {
account: state.getIn(['mutes', 'new', 'account']), account: state.getIn(['mutes', 'new', 'account']),
notifications: state.getIn(['mutes', 'new', 'notifications']), notifications: state.getIn(['mutes', 'new', 'notifications']),
muteDuration: state.getIn(['mutes', 'new', 'duration']),
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
onConfirm(account, notifications) { onConfirm(account, notifications, muteDuration) {
dispatch(muteAccount(account.get('id'), notifications)); dispatch(muteAccount(account.get('id'), notifications, muteDuration));
}, },
onClose() { onClose() {
@ -29,6 +36,10 @@ const mapDispatchToProps = dispatch => {
onToggleNotifications() { onToggleNotifications() {
dispatch(toggleHideNotifications()); dispatch(toggleHideNotifications());
}, },
onChangeMuteDuration(e) {
dispatch(changeMuteDuration(e.target.value));
},
}; };
}; };
@ -43,6 +54,8 @@ class MuteModal extends React.PureComponent {
onConfirm: PropTypes.func.isRequired, onConfirm: PropTypes.func.isRequired,
onToggleNotifications: PropTypes.func.isRequired, onToggleNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
muteDuration: PropTypes.number.isRequired,
onChangeMuteDuration: PropTypes.func.isRequired,
}; };
componentDidMount() { componentDidMount() {
@ -51,7 +64,7 @@ class MuteModal extends React.PureComponent {
handleClick = () => { handleClick = () => {
this.props.onClose(); this.props.onClose();
this.props.onConfirm(this.props.account, this.props.notifications); this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration);
} }
handleCancel = () => { handleCancel = () => {
@ -66,8 +79,12 @@ class MuteModal extends React.PureComponent {
this.props.onToggleNotifications(); this.props.onToggleNotifications();
} }
changeMuteDuration = (e) => {
this.props.onChangeMuteDuration(e);
}
render () { render () {
const { account, notifications } = this.props; const { account, notifications, muteDuration, intl } = this.props;
return ( return (
<div className='modal-root__modal mute-modal'> <div className='modal-root__modal mute-modal'>
@ -91,6 +108,21 @@ class MuteModal extends React.PureComponent {
<FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
</label> </label>
</div> </div>
<div>
<span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select value={muteDuration} onChange={this.changeMuteDuration}>
<option value={0}>{intl.formatMessage(messages.indefinite)}</option>
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
</select>
</div>
</div> </div>
<div className='mute-modal__action-bar'> <div className='mute-modal__action-bar'>

@ -359,7 +359,7 @@ class UI extends React.Component {
const visibility = !document[this.visibilityHiddenProp]; const visibility = !document[this.visibilityHiddenProp];
this.props.dispatch(notificationsSetVisibility(visibility)); this.props.dispatch(notificationsSetVisibility(visibility));
if (visibility) { if (visibility) {
this.props.dispatch(submitMarkers()); this.props.dispatch(submitMarkers({ immediate: true }));
} }
} }
@ -385,7 +385,7 @@ class UI extends React.Component {
componentDidMount () { componentDidMount () {
this.hotkeys.__mousetrap__.stopCallback = (e, element) => { this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
}; };
if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support

@ -1,3 +1,4 @@
import 'packs/public-path';
import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import loadPolyfills from 'flavours/glitch/util/load_polyfills';
function loaded() { function loaded() {

@ -1,3 +1,4 @@
import 'packs/public-path';
import { start } from '@rails/ujs'; import { start } from '@rails/ujs';
start(); start();

@ -1,3 +1,4 @@
import 'packs/public-path';
import ready from 'flavours/glitch/util/ready'; import ready from 'flavours/glitch/util/ready';
ready(() => { ready(() => {

@ -1,3 +1,4 @@
import 'packs/public-path';
import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import loadPolyfills from 'flavours/glitch/util/load_polyfills';
loadPolyfills().then(() => { loadPolyfills().then(() => {

@ -1,3 +1,4 @@
import 'packs/public-path';
import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import loadPolyfills from 'flavours/glitch/util/load_polyfills';
import ready from 'flavours/glitch/util/ready'; import ready from 'flavours/glitch/util/ready';
import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';

@ -1,3 +1,4 @@
import 'packs/public-path';
import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import loadPolyfills from 'flavours/glitch/util/load_polyfills';
import ready from 'flavours/glitch/util/ready'; import ready from 'flavours/glitch/util/ready';
import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';

@ -1,3 +1,4 @@
import 'packs/public-path';
import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import loadPolyfills from 'flavours/glitch/util/load_polyfills';
function loaded() { function loaded() {

@ -3,12 +3,14 @@ import Immutable from 'immutable';
import { import {
MUTES_INIT_MODAL, MUTES_INIT_MODAL,
MUTES_TOGGLE_HIDE_NOTIFICATIONS, MUTES_TOGGLE_HIDE_NOTIFICATIONS,
MUTES_CHANGE_DURATION,
} from 'flavours/glitch/actions/mutes'; } from 'flavours/glitch/actions/mutes';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
new: Immutable.Map({ new: Immutable.Map({
account: null, account: null,
notifications: true, notifications: true,
duration: 0,
}), }),
}); });
@ -21,6 +23,8 @@ export default function mutes(state = initialState, action) {
}); });
case MUTES_TOGGLE_HIDE_NOTIFICATIONS: case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
return state.updateIn(['new', 'notifications'], (old) => !old); return state.updateIn(['new', 'notifications'], (old) => !old);
case MUTES_CHANGE_DURATION:
return state.setIn(['new', 'duration'], Number(action.duration));
default: default:
return state; return state;
} }

@ -17,6 +17,8 @@ import {
NOTIFICATIONS_ENTER_CLEARING_MODE, NOTIFICATIONS_ENTER_CLEARING_MODE,
NOTIFICATIONS_MARK_ALL_FOR_DELETE, NOTIFICATIONS_MARK_ALL_FOR_DELETE,
NOTIFICATIONS_MARK_AS_READ, NOTIFICATIONS_MARK_AS_READ,
NOTIFICATIONS_SET_BROWSER_SUPPORT,
NOTIFICATIONS_SET_BROWSER_PERMISSION,
} from 'flavours/glitch/actions/notifications'; } from 'flavours/glitch/actions/notifications';
import { import {
ACCOUNT_BLOCK_SUCCESS, ACCOUNT_BLOCK_SUCCESS,
@ -44,6 +46,8 @@ const initialState = ImmutableMap({
isLoading: false, isLoading: false,
cleaningMode: false, cleaningMode: false,
isTabVisible: true, isTabVisible: true,
browserSupport: false,
browserPermission: 'default',
// notification removal mark of new notifs loaded whilst cleaningMode is true. // notification removal mark of new notifs loaded whilst cleaningMode is true.
markNewForDelete: false, markNewForDelete: false,
}); });
@ -185,7 +189,7 @@ const deleteMarkedNotifs = (state) => {
const updateMounted = (state) => { const updateMounted = (state) => {
state = state.update('mounted', count => count + 1); state = state.update('mounted', count => count + 1);
if (!shouldCountUnreadNotifications(state)) { if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) {
state = state.set('readMarkerId', state.get('lastReadId')); state = state.set('readMarkerId', state.get('lastReadId'));
state = clearUnread(state); state = clearUnread(state);
} }
@ -201,7 +205,7 @@ const updateVisibility = (state, visibility) => {
return state; return state;
}; };
const shouldCountUnreadNotifications = (state) => { const shouldCountUnreadNotifications = (state, ignoreScroll = false) => {
const isTabVisible = state.get('isTabVisible'); const isTabVisible = state.get('isTabVisible');
const isOnTop = state.get('top'); const isOnTop = state.get('top');
const isMounted = state.get('mounted') > 0; const isMounted = state.get('mounted') > 0;
@ -209,7 +213,7 @@ const shouldCountUnreadNotifications = (state) => {
const lastItem = state.get('items').findLast(item => item !== null); const lastItem = state.get('items').findLast(item => item !== null);
const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0); const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0);
return !(isTabVisible && isOnTop && isMounted && lastItemReached); return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached);
}; };
const recountUnread = (state, last_read_id) => { const recountUnread = (state, last_read_id) => {
@ -275,6 +279,10 @@ export default function notifications(state = initialState, action) {
return action.timeline === 'home' ? return action.timeline === 'home' ?
state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
state; state;
case NOTIFICATIONS_SET_BROWSER_SUPPORT:
return state.set('browserSupport', action.value);
case NOTIFICATIONS_SET_BROWSER_PERMISSION:
return state.set('browserPermission', action.value);
case NOTIFICATION_MARK_FOR_DELETE: case NOTIFICATION_MARK_FOR_DELETE:
return markForDelete(state, action.id, action.yes); return markForDelete(state, action.id, action.yes);

@ -45,7 +45,7 @@ const initialState = ImmutableMap();
export default function relationships(state = initialState, action) { export default function relationships(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ACCOUNT_FOLLOW_REQUEST: case ACCOUNT_FOLLOW_REQUEST:
return state.setIn([action.id, action.locked ? 'requested' : 'following'], true); return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
case ACCOUNT_FOLLOW_FAIL: case ACCOUNT_FOLLOW_FAIL:
return state.setIn([action.id, action.locked ? 'requested' : 'following'], false); return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
case ACCOUNT_UNFOLLOW_REQUEST: case ACCOUNT_UNFOLLOW_REQUEST:

@ -33,12 +33,13 @@ const initialState = ImmutableMap({
notifications: ImmutableMap({ notifications: ImmutableMap({
alerts: ImmutableMap({ alerts: ImmutableMap({
follow: true, follow: false,
follow_request: false, follow_request: false,
favourite: true, favourite: false,
reblog: true, reblog: false,
mention: true, mention: false,
poll: true, poll: false,
status: false,
}), }),
quickFilter: ImmutableMap({ quickFilter: ImmutableMap({
@ -54,6 +55,7 @@ const initialState = ImmutableMap({
reblog: true, reblog: true,
mention: true, mention: true,
poll: true, poll: true,
status: true,
}), }),
sounds: ImmutableMap({ sounds: ImmutableMap({
@ -63,6 +65,7 @@ const initialState = ImmutableMap({
reblog: true, reblog: true,
mention: true, mention: true,
poll: true, poll: true,
status: true,
}), }),
}), }),

@ -254,127 +254,6 @@
text-align: center; text-align: center;
} }
.column-settings__outer {
background: lighten($ui-base-color, 8%);
padding: 15px;
}
.column-settings__section {
color: $darker-text-color;
cursor: default;
display: block;
font-weight: 500;
margin-bottom: 10px;
}
.column-settings__hashtags {
.column-settings__row {
margin-bottom: 15px;
}
.column-select {
&__control {
@include search-input();
&::placeholder {
color: lighten($darker-text-color, 4%);
}
&::-moz-focus-inner {
border: 0;
}
&::-moz-focus-inner,
&:focus,
&:active {
outline: 0 !important;
}
&:focus {
background: lighten($ui-base-color, 4%);
}
@media screen and (max-width: 600px) {
font-size: 16px;
}
}
&__placeholder {
color: $dark-text-color;
padding-left: 2px;
font-size: 12px;
}
&__value-container {
padding-left: 6px;
}
&__multi-value {
background: lighten($ui-base-color, 8%);
&__remove {
cursor: pointer;
&:hover,
&:active,
&:focus {
background: lighten($ui-base-color, 12%);
color: lighten($darker-text-color, 4%);
}
}
}
&__multi-value__label,
&__input {
color: $darker-text-color;
}
&__clear-indicator,
&__dropdown-indicator {
cursor: pointer;
transition: none;
color: $dark-text-color;
&:hover,
&:active,
&:focus {
color: lighten($dark-text-color, 4%);
}
}
&__indicator-separator {
background-color: lighten($ui-base-color, 8%);
}
&__menu {
@include search-popout();
padding: 0;
background: $ui-secondary-color;
}
&__menu-list {
padding: 6px;
}
&__option {
color: $inverted-text-color;
border-radius: 4px;
font-size: 14px;
&--is-focused,
&--is-selected {
background: darken($ui-secondary-color, 10%);
}
}
}
}
.column-settings__row {
.text-btn {
margin-bottom: 15px;
}
}
.relationship-tag { .relationship-tag {
color: $primary-text-color; color: $primary-text-color;
margin-bottom: 4px; margin-bottom: 4px;

@ -463,6 +463,15 @@
flex: 1; flex: 1;
} }
.column-header__issue-btn {
color: $warning-red;
&:hover {
color: $error-red;
text-decoration: underline;
}
}
.column-header__icon { .column-header__icon {
display: inline-block; display: inline-block;
margin-right: 5px; margin-right: 5px;
@ -560,3 +569,150 @@
margin: 0 5px; margin: 0 5px;
} }
} }
.column-settings__outer {
background: lighten($ui-base-color, 8%);
padding: 15px;
}
.column-settings__section {
color: $darker-text-color;
cursor: default;
display: block;
font-weight: 500;
margin-bottom: 10px;
}
.column-settings__row--with-margin {
margin-bottom: 15px;
}
.column-settings__hashtags {
.column-settings__row {
margin-bottom: 15px;
}
.column-select {
&__control {
@include search-input();
&::placeholder {
color: lighten($darker-text-color, 4%);
}
&::-moz-focus-inner {
border: 0;
}
&::-moz-focus-inner,
&:focus,
&:active {
outline: 0 !important;
}
&:focus {
background: lighten($ui-base-color, 4%);
}
@media screen and (max-width: 600px) {
font-size: 16px;
}
}
&__placeholder {
color: $dark-text-color;
padding-left: 2px;
font-size: 12px;
}
&__value-container {
padding-left: 6px;
}
&__multi-value {
background: lighten($ui-base-color, 8%);
&__remove {
cursor: pointer;
&:hover,
&:active,
&:focus {
background: lighten($ui-base-color, 12%);
color: lighten($darker-text-color, 4%);
}
}
}
&__multi-value__label,
&__input {
color: $darker-text-color;
}
&__clear-indicator,
&__dropdown-indicator {
cursor: pointer;
transition: none;
color: $dark-text-color;
&:hover,
&:active,
&:focus {
color: lighten($dark-text-color, 4%);
}
}
&__indicator-separator {
background-color: lighten($ui-base-color, 8%);
}
&__menu {
@include search-popout();
padding: 0;
background: $ui-secondary-color;
}
&__menu-list {
padding: 6px;
}
&__option {
color: $inverted-text-color;
border-radius: 4px;
font-size: 14px;
&--is-focused,
&--is-selected {
background: darken($ui-secondary-color, 10%);
}
}
}
}
.column-settings__row {
.text-btn {
margin-bottom: 15px;
}
}
.notifications-permission-banner {
padding: 30px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
h2 {
font-size: 16px;
font-weight: 500;
margin-bottom: 15px;
text-align: center;
}
p {
color: $darker-text-color;
margin-bottom: 15px;
text-align: center;
}
}

@ -708,6 +708,17 @@
line-height: 14px; line-height: 14px;
color: $primary-text-color; color: $primary-text-color;
} }
&__issue-badge {
position: absolute;
left: 11px;
bottom: 1px;
display: block;
background: $error-red;
border-radius: 50%;
width: 0.625rem;
height: 0.625rem;
}
} }
.column-link--transparent .icon-with-badge__badge { .column-link--transparent .icon-with-badge__badge {

@ -785,6 +785,22 @@
} }
} }
} }
select {
appearance: none;
box-sizing: border-box;
font-size: 14px;
color: $inverted-text-color;
display: inline-block;
width: auto;
outline: 0;
font-family: inherit;
background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px;
border: 1px solid darken($simple-background-color, 14%);
border-radius: 4px;
padding: 6px 10px;
padding-right: 30px;
}
} }
.confirmation-modal__container, .confirmation-modal__container,

@ -385,3 +385,8 @@
.directory__tag > div { .directory__tag > div {
box-shadow: none; box-shadow: none;
} }
.mute-modal select {
border: 1px solid lighten($ui-base-color, 8%);
background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px;
}

@ -0,0 +1,10 @@
import ready from './ready';
export let assetHost = '';
ready(() => {
const cdnHost = document.querySelector('meta[name=cdn-host]');
if (cdnHost) {
assetHost = cdnHost.content || '';
}
});

@ -1,11 +1,10 @@
import { autoPlayGif, useSystemEmojiFont } from 'flavours/glitch/util/initial_state'; import { autoPlayGif, useSystemEmojiFont } from 'flavours/glitch/util/initial_state';
import unicodeMapping from './emoji_unicode_mapping_light'; import unicodeMapping from './emoji_unicode_mapping_light';
import { assetHost } from 'flavours/glitch/util/config';
import Trie from 'substring-trie'; import Trie from 'substring-trie';
const trie = new Trie(Object.keys(unicodeMapping)); const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
// Convert to file names from emojis. (For different variation selector emojis) // Convert to file names from emojis. (For different variation selector emojis)
const emojiFilenames = (emojis) => { const emojiFilenames = (emojis) => {
return emojis.map(v => unicodeMapping[v].filename); return emojis.map(v => unicodeMapping[v].filename);

@ -1,4 +1,5 @@
import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications'; import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications';
import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon'; import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
@ -22,6 +23,7 @@ function main() {
const props = JSON.parse(mountNode.getAttribute('data-props')); const props = JSON.parse(mountNode.getAttribute('data-props'));
ReactDOM.render(<Mastodon {...props} />, mountNode); ReactDOM.render(<Mastodon {...props} />, mountNode);
store.dispatch(setupBrowserNotifications());
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
// avoid offline in dev mode because it's harder to debug // avoid offline in dev mode because it's harder to debug
require('offline-plugin/runtime').install(); require('offline-plugin/runtime').install();

@ -0,0 +1,29 @@
// Handles browser quirks, based on
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
const checkNotificationPromise = () => {
try {
Notification.requestPermission().then();
} catch(e) {
return false;
}
return true;
};
const handlePermission = (permission, callback) => {
// Whatever the user answers, we make sure Chrome stores the information
if(!('permission' in Notification)) {
Notification.permission = permission;
}
callback(Notification.permission);
};
export const requestNotificationPermission = (callback) => {
if (checkNotificationPromise()) {
Notification.requestPermission().then((permission) => handlePermission(permission, callback));
} else {
Notification.requestPermission((permission) => handlePermission(permission, callback));
}
};

@ -257,11 +257,11 @@ export function unblockAccountFail(error) {
}; };
export function muteAccount(id, notifications) { export function muteAccount(id, notifications, duration=0) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(muteAccountRequest(id)); dispatch(muteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
}).catch(error => { }).catch(error => {

@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) {
}; };
}; };
export function submitMarkers() { export function submitMarkers(params = {}) {
return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
if (params.immediate === true) {
debouncedSubmitMarkers.flush();
}
return result;
}; };
export const fetchMarkers = () => (dispatch, getState) => { export const fetchMarkers = () => (dispatch, getState) => {

@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
export function fetchMutes() { export function fetchMutes() {
return (dispatch, getState) => { return (dispatch, getState) => {
@ -104,3 +105,12 @@ export function toggleHideNotifications() {
dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
}; };
} }
export function changeMuteDuration(duration) {
return dispatch => {
dispatch({
type: MUTES_CHANGE_DURATION,
duration,
});
};
}

@ -16,6 +16,7 @@ import { getFiltersRegex } from '../selectors';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import compareId from 'mastodon/compare_id'; import compareId from 'mastodon/compare_id';
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
import { requestNotificationPermission } from '../utils/notifications';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@ -33,8 +34,12 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; 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_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
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' },
@ -234,3 +239,47 @@ export const mountNotifications = () => ({
export const unmountNotifications = () => ({ export const unmountNotifications = () => ({
type: NOTIFICATIONS_UNMOUNT, type: NOTIFICATIONS_UNMOUNT,
}); });
export const markNotificationsAsRead = () => ({
type: NOTIFICATIONS_MARK_AS_READ,
});
// Browser support
export function setupBrowserNotifications() {
return dispatch => {
dispatch(setBrowserSupport('Notification' in window));
if ('Notification' in window) {
dispatch(setBrowserPermission(Notification.permission));
}
if ('Notification' in window && 'permissions' in navigator) {
navigator.permissions.query({ name: 'notifications' }).then((status) => {
status.onchange = () => dispatch(setBrowserPermission(Notification.permission));
});
}
};
}
export function requestBrowserPermission(callback = noOp) {
return dispatch => {
requestNotificationPermission((permission) => {
dispatch(setBrowserPermission(permission));
callback(permission);
});
};
};
export function setBrowserSupport (value) {
return {
type: NOTIFICATIONS_SET_BROWSER_SUPPORT,
value,
};
}
export function setBrowserPermission (value) {
return {
type: NOTIFICATIONS_SET_BROWSER_PERMISSION,
value,
};
}

@ -1,8 +1,21 @@
import { changeSetting, saveSettings } from './settings'; import { changeSetting, saveSettings } from './settings';
import { requestBrowserPermission } from './notifications';
export const INTRODUCTION_VERSION = 20181216044202; export const INTRODUCTION_VERSION = 20181216044202;
export const closeOnboarding = () => dispatch => { export const closeOnboarding = () => dispatch => {
dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
dispatch(saveSettings()); dispatch(saveSettings());
dispatch(requestBrowserPermission((permission) => {
if (permission === 'granted') {
dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
dispatch(saveSettings());
}
}));
}; };

@ -8,6 +8,7 @@ import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state'; import { me } from '../initial_state';
import RelativeTimestamp from './relative_timestamp';
const messages = defineMessages({ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
@ -107,11 +108,17 @@ class Account extends ImmutablePureComponent {
} }
} }
let mute_expires_at;
if (account.get('mute_expires_at')) {
mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
}
return ( return (
<div className='account'> <div className='account'>
<div className='account__wrapper'> <div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}> <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
{mute_expires_at}
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>

@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
import { assetHost } from 'mastodon/utils/config';
const assetHost = process.env.CDN_HOST || '';
export default class AutosuggestEmoji extends React.PureComponent { export default class AutosuggestEmoji extends React.PureComponent {

@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent {
onMove: PropTypes.func, onMove: PropTypes.func,
onClick: PropTypes.func, onClick: PropTypes.func,
appendContent: PropTypes.node, appendContent: PropTypes.node,
collapseIssues: PropTypes.bool,
}; };
state = { state = {
@ -83,7 +84,7 @@ class ColumnHeader extends React.PureComponent {
} }
render () { render () {
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
const { collapsed, animating } = this.state; const { collapsed, animating } = this.state;
const wrapperClassName = classNames('column-header__wrapper', { const wrapperClassName = classNames('column-header__wrapper', {
@ -145,7 +146,20 @@ class ColumnHeader extends React.PureComponent {
} }
if (children || (multiColumn && this.props.onPin)) { if (children || (multiColumn && this.props.onPin)) {
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>; collapseButton = (
<button
className={collapsibleButtonClassName}
title={formatMessage(collapsed ? messages.show : messages.hide)}
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
aria-pressed={collapsed ? 'false' : 'true'}
onClick={this.handleToggleClick}
>
<i className='icon-with-badge'>
<Icon id='sliders' />
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i>
</button>
);
} }
const hasTitle = icon && title; const hasTitle = icon && title;

@ -116,6 +116,7 @@ export default class IconButton extends React.PureComponent {
activate, activate,
deactivate, deactivate,
overlayed: overlay, overlayed: overlay,
'icon-button--with-counter': typeof counter !== 'undefined',
}); });
if (typeof counter !== 'undefined') { if (typeof counter !== 'undefined') {

@ -4,16 +4,18 @@ import Icon from 'mastodon/components/icon';
const formatNumber = num => num > 40 ? '40+' : num; const formatNumber = num => num > 40 ? '40+' : num;
const IconWithBadge = ({ id, count, className }) => ( const IconWithBadge = ({ id, count, issueBadge, className }) => (
<i className='icon-with-badge'> <i className='icon-with-badge'>
<Icon id={id} fixedWidth className={className} /> <Icon id={id} fixedWidth className={className} />
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
{issueBadge && <i className='icon-with-badge__issue-badge' />}
</i> </i>
); );
IconWithBadge.propTypes = { IconWithBadge.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
count: PropTypes.number.isRequired, count: PropTypes.number.isRequired,
issueBadge: PropTypes.bool,
className: PropTypes.string, className: PropTypes.string,
}; };

@ -7,6 +7,7 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
import { assetHost } from 'mastodon/utils/config';
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@ -25,7 +26,6 @@ const messages = defineMessages({
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
}); });
const assetHost = process.env.CDN_HOST || '';
let EmojiPicker, Emoji; // load asynchronously let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;

@ -1,11 +1,10 @@
import { autoPlayGif } from '../../initial_state'; import { autoPlayGif } from '../../initial_state';
import unicodeMapping from './emoji_unicode_mapping_light'; import unicodeMapping from './emoji_unicode_mapping_light';
import { assetHost } from 'mastodon/utils/config';
import Trie from 'substring-trie'; import Trie from 'substring-trie';
const trie = new Trie(Object.keys(unicodeMapping)); const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
// Convert to file names from emojis. (For different variation selector emojis) // Convert to file names from emojis. (For different variation selector emojis)
const emojiFilenames = (emojis) => { const emojiFilenames = (emojis) => {
return emojis.map(v => unicodeMapping[v].filename); return emojis.map(v => unicodeMapping[v].filename);

@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
import { autoPlayGif, reduceMotion } from 'mastodon/initial_state'; import { autoPlayGif, reduceMotion, disableSwiping } from 'mastodon/initial_state';
import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg'; import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
import { mascot } from 'mastodon/initial_state'; import { mascot } from 'mastodon/initial_state';
import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_pick
import AnimatedNumber from 'mastodon/components/animated_number'; import AnimatedNumber from 'mastodon/components/animated_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion'; import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { assetHost } from 'mastodon/utils/config';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent {
} }
const assetHost = process.env.CDN_HOST || '';
class Emoji extends React.PureComponent { class Emoji extends React.PureComponent {
static propTypes = { static propTypes = {
@ -436,6 +435,7 @@ class Announcements extends ImmutablePureComponent {
removeReaction={this.props.removeReaction} removeReaction={this.props.removeReaction}
intl={intl} intl={intl}
selected={index === idx} selected={index === idx}
disabled={disableSwiping}
/> />
))} ))}
</ReactSwipeableViews> </ReactSwipeableViews>

@ -10,7 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, profile_directory, showTrends } from '../../initial_state'; import { me, profile_directory, showTrends } from '../../initial_state';
import { fetchFollowRequests } from 'mastodon/actions/accounts'; import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import NavigationBar from '../compose/components/navigation_bar'; import NavigationContainer from '../compose/containers/navigation_container';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import LinkFooter from 'mastodon/features/ui/components/link_footer'; import LinkFooter from 'mastodon/features/ui/components/link_footer';
import TrendsContainer from './containers/trends_container'; import TrendsContainer from './containers/trends_container';
@ -168,7 +168,7 @@ class GettingStarted extends ImmutablePureComponent {
<div className='getting-started'> <div className='getting-started'>
<div className='getting-started__wrapper' style={{ height }}> <div className='getting-started__wrapper' style={{ height }}>
{!multiColumn && <NavigationBar account={myAccount} />} {!multiColumn && <NavigationContainer />}
{navItems} {navItems}
</div> </div>

@ -9,6 +9,7 @@ import screenHello from '../../../images/screen_hello.svg';
import screenFederation from '../../../images/screen_federation.svg'; import screenFederation from '../../../images/screen_federation.svg';
import screenInteractions from '../../../images/screen_interactions.svg'; import screenInteractions from '../../../images/screen_interactions.svg';
import logoTransparent from '../../../images/logo_transparent.svg'; import logoTransparent from '../../../images/logo_transparent.svg';
import { disableSwiping } from 'mastodon/initial_state';
const FrameWelcome = ({ domain, onNext }) => ( const FrameWelcome = ({ domain, onNext }) => (
<div className='introduction__frame'> <div className='introduction__frame'>
@ -171,7 +172,7 @@ class Introduction extends React.PureComponent {
return ( return (
<div className='introduction'> <div className='introduction'>
<ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='introduction__pager'> <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} disabled={disableSwiping} className='introduction__pager'>
{pages.map((page, i) => ( {pages.map((page, i) => (
<div key={i} className={classNames('introduction__frame-wrapper', { 'active': i === currentIndex })}>{page}</div> <div key={i} className={classNames('introduction__frame-wrapper', { 'active': i === currentIndex })}>{page}</div>
))} ))}

@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent {
pushSettings: ImmutablePropTypes.map.isRequired, pushSettings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired,
onRequestNotificationPermission: PropTypes.func,
alertsEnabled: PropTypes.bool,
browserSupport: PropTypes.bool,
browserPermission: PropTypes.bool,
}; };
onPushChange = (path, checked) => { onPushChange = (path, checked) => {
@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent {
} }
render () { render () {
const { settings, pushSettings, onChange, onClear } = this.props; const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props;
const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
@ -32,6 +36,12 @@ export default class ColumnSettings extends React.PureComponent {
return ( return (
<div> <div>
{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>
</div>
)}
<div className='column-settings__row'> <div className='column-settings__row'>
<ClearColumnButton onClick={onClear} /> <ClearColumnButton onClick={onClear} />
</div> </div>
@ -40,6 +50,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-filter-bar' className='column-settings__section'> <span id='notifications-filter-bar' className='column-settings__section'>
<FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
</span> </span>
<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={filterShowStr} /> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
<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} />
@ -50,7 +61,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
@ -61,7 +72,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
@ -72,7 +83,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
@ -83,7 +94,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
@ -94,7 +105,7 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
@ -105,12 +116,23 @@ export default class ColumnSettings extends React.PureComponent {
<span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
<div role='group' aria-labelledby='notifications-status'>
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
</div>
</div>
</div> </div>
); );
} }

@ -0,0 +1,30 @@
import React from 'react';
import Icon from 'mastodon/components/icon';
import Button from 'mastodon/components/button';
import { requestBrowserPermission } from 'mastodon/actions/notifications';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
export default @connect(() => {})
class NotificationsPermissionBanner extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
};
handleClick = () => {
this.props.dispatch(requestBrowserPermission());
}
render () {
return (
<div className='notifications-permission-banner'>
<h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
<p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p>
<Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
</div>
);
}
}

@ -12,6 +12,7 @@ export default class SettingToggle extends React.PureComponent {
label: PropTypes.node.isRequired, label: PropTypes.node.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
defaultValue: PropTypes.bool, defaultValue: PropTypes.bool,
disabled: PropTypes.bool,
} }
onChange = ({ target }) => { onChange = ({ target }) => {
@ -19,12 +20,12 @@ export default class SettingToggle extends React.PureComponent {
} }
render () { render () {
const { prefix, settings, settingPath, label, defaultValue } = this.props; const { prefix, settings, settingPath, label, defaultValue, disabled } = this.props;
const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
return ( return (
<div className='setting-toggle'> <div className='setting-toggle'>
<Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
<label htmlFor={id} className='setting-toggle__label'>{label}</label> <label htmlFor={id} className='setting-toggle__label'>{label}</label>
</div> </div>
); );

@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl';
import ColumnSettings from '../components/column_settings'; import ColumnSettings from '../components/column_settings';
import { changeSetting } from '../../../actions/settings'; import { changeSetting } from '../../../actions/settings';
import { setFilter } from '../../../actions/notifications'; import { setFilter } from '../../../actions/notifications';
import { clearNotifications } from '../../../actions/notifications'; import { clearNotifications, requestBrowserPermission } from '../../../actions/notifications';
import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
import { openModal } from '../../../actions/modal'; import { openModal } from '../../../actions/modal';
import { showAlert } from '../../../actions/alerts';
const messages = defineMessages({ const messages = defineMessages({
clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
settings: state.getIn(['settings', 'notifications']), settings: state.getIn(['settings', 'notifications']),
pushSettings: state.get('push_notifications'), pushSettings: state.get('push_notifications'),
alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
browserSupport: state.getIn(['notifications', 'browserSupport']),
browserPermission: state.getIn(['notifications', 'browserPermission']),
}); });
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onChange (path, checked) { onChange (path, checked) {
if (path[0] === 'push') { if (path[0] === 'push') {
if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
dispatch(requestBrowserPermission((permission) => {
if (permission === 'granted') {
dispatch(changePushNotifications(path.slice(1), checked)); dispatch(changePushNotifications(path.slice(1), checked));
} else {
dispatch(showAlert(undefined, messages.permissionDenied));
}
}));
} else {
dispatch(changePushNotifications(path.slice(1), checked));
}
} else if (path[0] === 'quickFilter') { } else if (path[0] === 'quickFilter') {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
dispatch(setFilter('all')); dispatch(setFilter('all'));
} else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
dispatch(requestBrowserPermission((permission) => {
if (permission === 'granted') {
dispatch(changeSetting(['notifications', ...path], checked));
} else {
dispatch(showAlert(undefined, messages.permissionDenied));
}
}));
} else {
dispatch(changeSetting(['notifications', ...path], checked));
}
} else { } else {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
} }
@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
})); }));
}, },
onRequestNotificationPermission () {
dispatch(requestBrowserPermission());
},
}); });
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));

@ -12,6 +12,7 @@ import {
unmountNotifications, unmountNotifications,
markNotificationsAsRead, markNotificationsAsRead,
} from '../../actions/notifications'; } from '../../actions/notifications';
import { submitMarkers } from '../../actions/markers';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import NotificationContainer from './containers/notification_container'; import NotificationContainer from './containers/notification_container';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@ -24,6 +25,7 @@ import ScrollableList from '../../components/scrollable_list';
import LoadGap from '../../components/load_gap'; import LoadGap from '../../components/load_gap';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import compareId from 'mastodon/compare_id'; import compareId from 'mastodon/compare_id';
import NotificationsPermissionBanner from './components/notifications_permission_banner';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' }, title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@ -54,6 +56,7 @@ const mapStateToProps = state => ({
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
lastReadId: state.getIn(['notifications', 'readMarkerId']), lastReadId: state.getIn(['notifications', 'readMarkerId']),
canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default',
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -74,6 +77,7 @@ class Notifications extends React.PureComponent {
numPending: PropTypes.number, numPending: PropTypes.number,
lastReadId: PropTypes.string, lastReadId: PropTypes.string,
canMarkAsRead: PropTypes.bool, canMarkAsRead: PropTypes.bool,
needsNotificationPermission: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -162,10 +166,11 @@ class Notifications extends React.PureComponent {
handleMarkAsRead = () => { handleMarkAsRead = () => {
this.props.dispatch(markNotificationsAsRead()); this.props.dispatch(markNotificationsAsRead());
this.props.dispatch(submitMarkers({ immediate: true }));
}; };
render () { render () {
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props; const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
@ -209,6 +214,8 @@ class Notifications extends React.PureComponent {
showLoading={isLoading && notifications.size === 0} showLoading={isLoading && notifications.size === 0}
hasMore={hasMore} hasMore={hasMore}
numPending={numPending} numPending={numPending}
prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
alwaysPrepend
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
onLoadMore={this.handleLoadOlder} onLoadMore={this.handleLoadOlder}
onLoadPending={this.handleLoadPending} onLoadPending={this.handleLoadPending}

@ -8,6 +8,8 @@ import ReactSwipeableViews from 'react-swipeable-views';
import TabsBar, { links, getIndex, getLink } from './tabs_bar'; import TabsBar, { links, getIndex, getLink } from './tabs_bar';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { disableSwiping } from 'mastodon/initial_state';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading'; import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading'; import DrawerLoading from './drawer_loading';
@ -185,7 +187,7 @@ class ColumnsArea extends ImmutablePureComponent {
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>; const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
const content = columnIndex !== -1 ? ( const content = columnIndex !== -1 ? (
<ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}> <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}>
{links.map(this.renderView)} {links.map(this.renderView)}
</ReactSwipeableViews> </ReactSwipeableViews>
) : ( ) : (

@ -20,6 +20,7 @@ import GIFV from 'mastodon/components/gifv';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
import { assetHost } from 'mastodon/utils/config';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
.replace(/\n/g, ' ') .replace(/\n/g, ' ')
.replace(/\*\*\*\*\*\*/g, '\n\n'); .replace(/\*\*\*\*\*\*/g, '\n\n');
const assetHost = process.env.CDN_HOST || '';
class ImageLoader extends React.PureComponent { class ImageLoader extends React.PureComponent {
static propTypes = { static propTypes = {

@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import ImageLoader from './image_loader'; import ImageLoader from './image_loader';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import GIFV from 'mastodon/components/gifv'; import GIFV from 'mastodon/components/gifv';
import { disableSwiping } from 'mastodon/initial_state';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -212,6 +213,7 @@ class MediaModal extends ImmutablePureComponent {
containerStyle={containerStyle} containerStyle={containerStyle}
onChangeIndex={this.handleSwipe} onChangeIndex={this.handleSwipe}
index={index} index={index}
disabled={disableSwiping}
> >
{content} {content}
</ReactSwipeableViews> </ReactSwipeableViews>

@ -1,25 +1,32 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle'; import Toggle from 'react-toggle';
import Button from '../../../components/button'; import Button from '../../../components/button';
import { closeModal } from '../../../actions/modal'; import { closeModal } from '../../../actions/modal';
import { muteAccount } from '../../../actions/accounts'; import { muteAccount } from '../../../actions/accounts';
import { toggleHideNotifications } from '../../../actions/mutes'; import { toggleHideNotifications, changeMuteDuration } from '../../../actions/mutes';
const messages = defineMessages({
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' },
});
const mapStateToProps = state => { const mapStateToProps = state => {
return { return {
account: state.getIn(['mutes', 'new', 'account']), account: state.getIn(['mutes', 'new', 'account']),
notifications: state.getIn(['mutes', 'new', 'notifications']), notifications: state.getIn(['mutes', 'new', 'notifications']),
muteDuration: state.getIn(['mutes', 'new', 'duration']),
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
onConfirm(account, notifications) { onConfirm(account, notifications, muteDuration) {
dispatch(muteAccount(account.get('id'), notifications)); dispatch(muteAccount(account.get('id'), notifications, muteDuration));
}, },
onClose() { onClose() {
@ -29,6 +36,10 @@ const mapDispatchToProps = dispatch => {
onToggleNotifications() { onToggleNotifications() {
dispatch(toggleHideNotifications()); dispatch(toggleHideNotifications());
}, },
onChangeMuteDuration(e) {
dispatch(changeMuteDuration(e.target.value));
},
}; };
}; };
@ -43,6 +54,8 @@ class MuteModal extends React.PureComponent {
onConfirm: PropTypes.func.isRequired, onConfirm: PropTypes.func.isRequired,
onToggleNotifications: PropTypes.func.isRequired, onToggleNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
muteDuration: PropTypes.number.isRequired,
onChangeMuteDuration: PropTypes.func.isRequired,
}; };
componentDidMount() { componentDidMount() {
@ -51,7 +64,7 @@ class MuteModal extends React.PureComponent {
handleClick = () => { handleClick = () => {
this.props.onClose(); this.props.onClose();
this.props.onConfirm(this.props.account, this.props.notifications); this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration);
} }
handleCancel = () => { handleCancel = () => {
@ -66,8 +79,12 @@ class MuteModal extends React.PureComponent {
this.props.onToggleNotifications(); this.props.onToggleNotifications();
} }
changeMuteDuration = (e) => {
this.props.onChangeMuteDuration(e);
}
render () { render () {
const { account, notifications } = this.props; const { account, notifications, muteDuration, intl } = this.props;
return ( return (
<div className='modal-root__modal mute-modal'> <div className='modal-root__modal mute-modal'>
@ -91,6 +108,21 @@ class MuteModal extends React.PureComponent {
<FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
</label> </label>
</div> </div>
<div>
<span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select value={muteDuration} onChange={this.changeMuteDuration}>
<option value={0}>{intl.formatMessage(messages.indefinite)}</option>
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
</select>
</div>
</div> </div>
<div className='mute-modal__action-bar'> <div className='mute-modal__action-bar'>

@ -266,7 +266,7 @@ class UI extends React.PureComponent {
handleWindowFocus = () => { handleWindowFocus = () => {
this.props.dispatch(focusApp()); this.props.dispatch(focusApp());
this.props.dispatch(submitMarkers()); this.props.dispatch(submitMarkers({ immediate: true }));
} }
handleWindowBlur = () => { handleWindowBlur = () => {
@ -366,10 +366,6 @@ class UI extends React.PureComponent {
navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
} }
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
}
this.props.dispatch(fetchMarkers()); this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications()); this.props.dispatch(expandNotifications());
@ -379,7 +375,7 @@ class UI extends React.PureComponent {
componentDidMount () { componentDidMount () {
this.hotkeys.__mousetrap__.stopCallback = (e, element) => { this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
}; };
} }

@ -26,5 +26,6 @@ export const usePendingItems = getMeta('use_pending_items');
export const showTrends = getMeta('trends'); export const showTrends = getMeta('trends');
export const title = getMeta('title'); export const title = getMeta('title');
export const cropImages = getMeta('crop_images'); export const cropImages = getMeta('crop_images');
export const disableSwiping = getMeta('disable_swiping');
export default initialState; export default initialState;

@ -167,10 +167,18 @@
}, },
{ {
"descriptors": [ "descriptors": [
{
"defaultMessage": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.",
"id": "error.unexpected_crash.explanation_addons"
},
{ {
"defaultMessage": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.", "defaultMessage": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
"id": "error.unexpected_crash.explanation" "id": "error.unexpected_crash.explanation"
}, },
{
"defaultMessage": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
"id": "error.unexpected_crash.next_steps_addons"
},
{ {
"defaultMessage": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", "defaultMessage": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
"id": "error.unexpected_crash.next_steps" "id": "error.unexpected_crash.next_steps"
@ -265,6 +273,15 @@
], ],
"path": "app/javascript/mastodon/components/missing_indicator.json" "path": "app/javascript/mastodon/components/missing_indicator.json"
}, },
{
"descriptors": [
{
"defaultMessage": "Put it back",
"id": "picture_in_picture.restore"
}
],
"path": "app/javascript/mastodon/components/picture_in_picture_placeholder.json"
},
{ {
"descriptors": [ "descriptors": [
{ {
@ -633,6 +650,15 @@
], ],
"path": "app/javascript/mastodon/containers/status_container.json" "path": "app/javascript/mastodon/containers/status_container.json"
}, },
{
"descriptors": [
{
"defaultMessage": "Profile unavailable",
"id": "empty_column.account_unavailable"
}
],
"path": "app/javascript/mastodon/features/account_gallery/index.json"
},
{ {
"descriptors": [ "descriptors": [
{ {
@ -796,6 +822,14 @@
"defaultMessage": "Show boosts from @{name}", "defaultMessage": "Show boosts from @{name}",
"id": "account.show_reblogs" "id": "account.show_reblogs"
}, },
{
"defaultMessage": "Notify me when @{name} posts",
"id": "account.enable_notifications"
},
{
"defaultMessage": "Stop notifying me when @{name} posts",
"id": "account.disable_notifications"
},
{ {
"defaultMessage": "Pinned toots", "defaultMessage": "Pinned toots",
"id": "navigation_bar.pins" "id": "navigation_bar.pins"
@ -2125,6 +2159,18 @@
"defaultMessage": "Delete", "defaultMessage": "Delete",
"id": "confirmations.delete_list.confirm" "id": "confirmations.delete_list.confirm"
}, },
{
"defaultMessage": "Any followed user",
"id": "lists.replies_policy.all_replies"
},
{
"defaultMessage": "No one",
"id": "lists.replies_policy.no_replies"
},
{
"defaultMessage": "Members of the list",
"id": "lists.replies_policy.list_replies"
},
{ {
"defaultMessage": "Edit list", "defaultMessage": "Edit list",
"id": "lists.edit" "id": "lists.edit"
@ -2133,6 +2179,10 @@
"defaultMessage": "Delete list", "defaultMessage": "Delete list",
"id": "lists.delete" "id": "lists.delete"
}, },
{
"defaultMessage": "Show replies to:",
"id": "lists.replies_policy.title"
},
{ {
"defaultMessage": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.", "defaultMessage": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
"id": "empty_column.list" "id": "empty_column.list"
@ -2218,6 +2268,10 @@
"defaultMessage": "Push notifications", "defaultMessage": "Push notifications",
"id": "notifications.column_settings.push" "id": "notifications.column_settings.push"
}, },
{
"defaultMessage": "Desktop notifications are unavailable due to previously denied browser permissions request",
"id": "notifications.permission_denied"
},
{ {
"defaultMessage": "Quick filter bar", "defaultMessage": "Quick filter bar",
"id": "notifications.column_settings.filter_bar.category" "id": "notifications.column_settings.filter_bar.category"
@ -2245,6 +2299,10 @@
{ {
"defaultMessage": "Poll results:", "defaultMessage": "Poll results:",
"id": "notifications.column_settings.poll" "id": "notifications.column_settings.poll"
},
{
"defaultMessage": "New toots:",
"id": "notifications.column_settings.status"
} }
], ],
"path": "app/javascript/mastodon/features/notifications/components/column_settings.json" "path": "app/javascript/mastodon/features/notifications/components/column_settings.json"
@ -2271,6 +2329,10 @@
"defaultMessage": "Follows", "defaultMessage": "Follows",
"id": "notifications.filter.follows" "id": "notifications.filter.follows"
}, },
{
"defaultMessage": "Updates from people you follow",
"id": "notifications.filter.statuses"
},
{ {
"defaultMessage": "All", "defaultMessage": "All",
"id": "notifications.filter.all" "id": "notifications.filter.all"
@ -2313,6 +2375,10 @@
"defaultMessage": "{name} boosted your status", "defaultMessage": "{name} boosted your status",
"id": "notification.reblog" "id": "notification.reblog"
}, },
{
"defaultMessage": "{name} just posted",
"id": "notification.status"
},
{ {
"defaultMessage": "{name} has requested to follow you", "defaultMessage": "{name} has requested to follow you",
"id": "notification.follow_request" "id": "notification.follow_request"
@ -2320,6 +2386,23 @@
], ],
"path": "app/javascript/mastodon/features/notifications/components/notification.json" "path": "app/javascript/mastodon/features/notifications/components/notification.json"
}, },
{
"descriptors": [
{
"defaultMessage": "Never miss a thing",
"id": "notifications_permission_banner.title"
},
{
"defaultMessage": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
"id": "notifications_permission_banner.how_to_control"
},
{
"defaultMessage": "Enable desktop notifications",
"id": "notifications_permission_banner.enable"
}
],
"path": "app/javascript/mastodon/features/notifications/components/notifications_permission_banner.json"
},
{ {
"descriptors": [ "descriptors": [
{ {
@ -2329,6 +2412,10 @@
{ {
"defaultMessage": "Clear notifications", "defaultMessage": "Clear notifications",
"id": "notifications.clear" "id": "notifications.clear"
},
{
"defaultMessage": "Desktop notifications can't be enabled, as browser permission has been denied before",
"id": "notifications.permission_denied_alert"
} }
], ],
"path": "app/javascript/mastodon/features/notifications/containers/column_settings_container.json" "path": "app/javascript/mastodon/features/notifications/containers/column_settings_container.json"
@ -2339,6 +2426,10 @@
"defaultMessage": "Notifications", "defaultMessage": "Notifications",
"id": "column.notifications" "id": "column.notifications"
}, },
{
"defaultMessage": "Mark every notification as read",
"id": "notifications.mark_as_read"
},
{ {
"defaultMessage": "You don't have any notifications yet. Interact with others to start the conversation.", "defaultMessage": "You don't have any notifications yet. Interact with others to start the conversation.",
"id": "empty_column.notifications" "id": "empty_column.notifications"
@ -2346,6 +2437,47 @@
], ],
"path": "app/javascript/mastodon/features/notifications/index.json" "path": "app/javascript/mastodon/features/notifications/index.json"
}, },
{
"descriptors": [
{
"defaultMessage": "Reply",
"id": "status.reply"
},
{
"defaultMessage": "Reply to thread",
"id": "status.replyAll"
},
{
"defaultMessage": "Boost",
"id": "status.reblog"
},
{
"defaultMessage": "Boost with original visibility",
"id": "status.reblog_private"
},
{
"defaultMessage": "Unboost",
"id": "status.cancel_reblog_private"
},
{
"defaultMessage": "This post cannot be boosted",
"id": "status.cannot_reblog"
},
{
"defaultMessage": "Favourite",
"id": "status.favourite"
},
{
"defaultMessage": "Reply",
"id": "confirmations.reply.confirm"
},
{
"defaultMessage": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"id": "confirmations.reply.message"
}
],
"path": "app/javascript/mastodon/features/picture_in_picture/components/footer.json"
},
{ {
"descriptors": [ "descriptors": [
{ {
@ -2798,6 +2930,14 @@
"defaultMessage": "Describe for the visually impaired", "defaultMessage": "Describe for the visually impaired",
"id": "upload_form.description" "id": "upload_form.description"
}, },
{
"defaultMessage": "Analyzing picture…",
"id": "upload_modal.analyzing_picture"
},
{
"defaultMessage": "Preparing OCR…",
"id": "upload_modal.preparing_ocr"
},
{ {
"defaultMessage": "Edit media", "defaultMessage": "Edit media",
"id": "upload_modal.edit_media" "id": "upload_modal.edit_media"
@ -2810,10 +2950,6 @@
"defaultMessage": "Change thumbnail", "defaultMessage": "Change thumbnail",
"id": "upload_form.thumbnail" "id": "upload_form.thumbnail"
}, },
{
"defaultMessage": "Analyzing picture…",
"id": "upload_modal.analyzing_picture"
},
{ {
"defaultMessage": "Detect text from picture", "defaultMessage": "Detect text from picture",
"id": "upload_modal.detect_text" "id": "upload_modal.detect_text"
@ -2910,6 +3046,22 @@
}, },
{ {
"descriptors": [ "descriptors": [
{
"defaultMessage": "{number, plural, one {# minute} other {# minutes}}",
"id": "intervals.full.minutes"
},
{
"defaultMessage": "{number, plural, one {# hour} other {# hours}}",
"id": "intervals.full.hours"
},
{
"defaultMessage": "{number, plural, one {# day} other {# days}}",
"id": "intervals.full.days"
},
{
"defaultMessage": "Indefinite",
"id": "mute_modal.indefinite"
},
{ {
"defaultMessage": "Are you sure you want to mute {name}?", "defaultMessage": "Are you sure you want to mute {name}?",
"id": "confirmations.mute.message" "id": "confirmations.mute.message"
@ -2922,6 +3074,10 @@
"defaultMessage": "Hide notifications from this user?", "defaultMessage": "Hide notifications from this user?",
"id": "mute_modal.hide_notifications" "id": "mute_modal.hide_notifications"
}, },
{
"defaultMessage": "Duration",
"id": "mute_modal.duration"
},
{ {
"defaultMessage": "Cancel", "defaultMessage": "Cancel",
"id": "confirmation_modal.cancel" "id": "confirmation_modal.cancel"

@ -9,8 +9,10 @@
"account.browse_more_on_origin_server": "Browse more on the original profile", "account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Cancel follow request", "account.cancel_follow_request": "Cancel follow request",
"account.direct": "Direct message @{name}", "account.direct": "Direct message @{name}",
"account.disable_notifications": "Stop notifying me when @{name} posts",
"account.domain_blocked": "Domain blocked", "account.domain_blocked": "Domain blocked",
"account.edit_profile": "Edit profile", "account.edit_profile": "Edit profile",
"account.enable_notifications": "Notify me when @{name} posts",
"account.endorse": "Feature on profile", "account.endorse": "Feature on profile",
"account.follow": "Follow", "account.follow": "Follow",
"account.followers": "Followers", "account.followers": "Followers",
@ -170,7 +172,9 @@
"empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up", "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.", "error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
"error.unexpected_crash.explanation_addons": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.",
"error.unexpected_crash.next_steps": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", "error.unexpected_crash.next_steps": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
"error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
"errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard", "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
"errors.unexpected_crash.report_issue": "Report issue", "errors.unexpected_crash.report_issue": "Report issue",
"follow_request.authorize": "Authorize", "follow_request.authorize": "Authorize",
@ -264,6 +268,10 @@
"lists.edit.submit": "Change title", "lists.edit.submit": "Change title",
"lists.new.create": "Add list", "lists.new.create": "Add list",
"lists.new.title_placeholder": "New list title", "lists.new.title_placeholder": "New list title",
"lists.replies_policy.all_replies": "Any followed user",
"lists.replies_policy.list_replies": "Members of the list",
"lists.replies_policy.no_replies": "No one",
"lists.replies_policy.title": "Show replies to:",
"lists.search": "Search among people you follow", "lists.search": "Search among people you follow",
"lists.subheading": "Your lists", "lists.subheading": "Your lists",
"load_pending": "{count, plural, one {# new item} other {# new items}}", "load_pending": "{count, plural, one {# new item} other {# new items}}",
@ -271,7 +279,9 @@
"media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}", "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
"missing_indicator.label": "Not found", "missing_indicator.label": "Not found",
"missing_indicator.sublabel": "This resource could not be found", "missing_indicator.sublabel": "This resource could not be found",
"mute_modal.duration": "Duration",
"mute_modal.hide_notifications": "Hide notifications from this user?", "mute_modal.hide_notifications": "Hide notifications from this user?",
"mute_modal.indefinite": "Indefinite",
"navigation_bar.apps": "Mobile apps", "navigation_bar.apps": "Mobile apps",
"navigation_bar.blocks": "Blocked users", "navigation_bar.blocks": "Blocked users",
"navigation_bar.bookmarks": "Bookmarks", "navigation_bar.bookmarks": "Bookmarks",
@ -303,6 +313,7 @@
"notification.own_poll": "Your poll has ended", "notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you have voted in has ended", "notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} boosted your toot", "notification.reblog": "{name} boosted your toot",
"notification.status": "{name} just posted",
"notifications.clear": "Clear notifications", "notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.column_settings.alert": "Desktop notifications", "notifications.column_settings.alert": "Desktop notifications",
@ -318,13 +329,22 @@
"notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.reblog": "Boosts:",
"notifications.column_settings.show": "Show in column", "notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound", "notifications.column_settings.sound": "Play sound",
"notifications.column_settings.status": "New toots:",
"notifications.filter.all": "All", "notifications.filter.all": "All",
"notifications.filter.boosts": "Boosts", "notifications.filter.boosts": "Boosts",
"notifications.filter.favourites": "Favourites", "notifications.filter.favourites": "Favourites",
"notifications.filter.follows": "Follows", "notifications.filter.follows": "Follows",
"notifications.filter.mentions": "Mentions", "notifications.filter.mentions": "Mentions",
"notifications.filter.polls": "Poll results", "notifications.filter.polls": "Poll results",
"notifications.filter.statuses": "Updates from people you follow",
"notifications.group": "{count} notifications", "notifications.group": "{count} notifications",
"notifications.mark_as_read": "Mark every notification as read",
"notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
"notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
"notifications_permission_banner.enable": "Enable desktop notifications",
"notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
"notifications_permission_banner.title": "Never miss a thing",
"picture_in_picture.restore": "Put it back",
"poll.closed": "Closed", "poll.closed": "Closed",
"poll.refresh": "Refresh", "poll.refresh": "Refresh",
"poll.total_people": "{count, plural, one {# person} other {# people}}", "poll.total_people": "{count, plural, one {# person} other {# people}}",
@ -451,6 +471,7 @@
"upload_modal.detect_text": "Detect text from picture", "upload_modal.detect_text": "Detect text from picture",
"upload_modal.edit_media": "Edit media", "upload_modal.edit_media": "Edit media",
"upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.", "upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
"upload_modal.preparing_ocr": "Preparing OCR…",
"upload_modal.preview_label": "Preview ({ratio})", "upload_modal.preview_label": "Preview ({ratio})",
"upload_progress.label": "Uploading...", "upload_progress.label": "Uploading...",
"video.close": "Close video", "video.close": "Close video",

@ -272,6 +272,8 @@
"missing_indicator.label": "見つかりません", "missing_indicator.label": "見つかりません",
"missing_indicator.sublabel": "見つかりませんでした", "missing_indicator.sublabel": "見つかりませんでした",
"mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?", "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?",
"mute_modal.duration": "ミュートする期間",
"mute_modal.indefinite": "無期限",
"navigation_bar.apps": "アプリ", "navigation_bar.apps": "アプリ",
"navigation_bar.blocks": "ブロックしたユーザー", "navigation_bar.blocks": "ブロックしたユーザー",
"navigation_bar.bookmarks": "ブックマーク", "navigation_bar.bookmarks": "ブックマーク",

@ -1,4 +1,5 @@
import * as registerPushNotifications from './actions/push_notifications'; import * as registerPushNotifications from './actions/push_notifications';
import { setupBrowserNotifications } from './actions/notifications';
import { default as Mastodon, store } from './containers/mastodon'; import { default as Mastodon, store } from './containers/mastodon';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
@ -22,6 +23,7 @@ function main() {
const props = JSON.parse(mountNode.getAttribute('data-props')); const props = JSON.parse(mountNode.getAttribute('data-props'));
ReactDOM.render(<Mastodon {...props} />, mountNode); ReactDOM.render(<Mastodon {...props} />, mountNode);
store.dispatch(setupBrowserNotifications());
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
// avoid offline in dev mode because it's harder to debug // avoid offline in dev mode because it's harder to debug
require('offline-plugin/runtime').install(); require('offline-plugin/runtime').install();

@ -3,12 +3,14 @@ import Immutable from 'immutable';
import { import {
MUTES_INIT_MODAL, MUTES_INIT_MODAL,
MUTES_TOGGLE_HIDE_NOTIFICATIONS, MUTES_TOGGLE_HIDE_NOTIFICATIONS,
MUTES_CHANGE_DURATION,
} from '../actions/mutes'; } from '../actions/mutes';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
new: Immutable.Map({ new: Immutable.Map({
account: null, account: null,
notifications: true, notifications: true,
duration: 0,
}), }),
}); });
@ -21,6 +23,8 @@ export default function mutes(state = initialState, action) {
}); });
case MUTES_TOGGLE_HIDE_NOTIFICATIONS: case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
return state.updateIn(['new', 'notifications'], (old) => !old); return state.updateIn(['new', 'notifications'], (old) => !old);
case MUTES_CHANGE_DURATION:
return state.setIn(['new', 'duration'], Number(action.duration));
default: default:
return state; return state;
} }

@ -10,6 +10,8 @@ import {
NOTIFICATIONS_MOUNT, NOTIFICATIONS_MOUNT,
NOTIFICATIONS_UNMOUNT, NOTIFICATIONS_UNMOUNT,
NOTIFICATIONS_MARK_AS_READ, NOTIFICATIONS_MARK_AS_READ,
NOTIFICATIONS_SET_BROWSER_SUPPORT,
NOTIFICATIONS_SET_BROWSER_PERMISSION,
} from '../actions/notifications'; } from '../actions/notifications';
import { import {
ACCOUNT_BLOCK_SUCCESS, ACCOUNT_BLOCK_SUCCESS,
@ -40,6 +42,8 @@ const initialState = ImmutableMap({
readMarkerId: '0', readMarkerId: '0',
isTabVisible: true, isTabVisible: true,
isLoading: false, isLoading: false,
browserSupport: false,
browserPermission: 'default',
}); });
const notificationToMap = notification => ImmutableMap({ const notificationToMap = notification => ImmutableMap({
@ -151,7 +155,7 @@ const deleteByStatus = (state, statusId) => {
const updateMounted = (state) => { const updateMounted = (state) => {
state = state.update('mounted', count => count + 1); state = state.update('mounted', count => count + 1);
if (!shouldCountUnreadNotifications(state)) { if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) {
state = state.set('readMarkerId', state.get('lastReadId')); state = state.set('readMarkerId', state.get('lastReadId'));
state = clearUnread(state); state = clearUnread(state);
} }
@ -167,14 +171,15 @@ const updateVisibility = (state, visibility) => {
return state; return state;
}; };
const shouldCountUnreadNotifications = (state) => { const shouldCountUnreadNotifications = (state, ignoreScroll = false) => {
const isTabVisible = state.get('isTabVisible'); const isTabVisible = state.get('isTabVisible');
const isOnTop = state.get('top'); const isOnTop = state.get('top');
const isMounted = state.get('mounted') > 0; const isMounted = state.get('mounted') > 0;
const lastReadId = state.get('lastReadId'); const lastReadId = state.get('lastReadId');
const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0); const lastItem = state.get('items').findLast(item => item !== null);
const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0);
return !(isTabVisible && isOnTop && isMounted && lastItemReached); return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached);
}; };
const recountUnread = (state, last_read_id) => { const recountUnread = (state, last_read_id) => {
@ -241,6 +246,10 @@ export default function notifications(state = initialState, action) {
case NOTIFICATIONS_MARK_AS_READ: case NOTIFICATIONS_MARK_AS_READ:
const lastNotification = state.get('items').find(item => item !== null); const lastNotification = state.get('items').find(item => item !== null);
return lastNotification ? recountUnread(state, lastNotification.get('id')) : state; return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
case NOTIFICATIONS_SET_BROWSER_SUPPORT:
return state.set('browserSupport', action.value);
case NOTIFICATIONS_SET_BROWSER_PERMISSION:
return state.set('browserPermission', action.value);
default: default:
return state; return state;
} }

@ -45,7 +45,7 @@ const initialState = ImmutableMap();
export default function relationships(state = initialState, action) { export default function relationships(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ACCOUNT_FOLLOW_REQUEST: case ACCOUNT_FOLLOW_REQUEST:
return state.setIn([action.id, action.locked ? 'requested' : 'following'], true); return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
case ACCOUNT_FOLLOW_FAIL: case ACCOUNT_FOLLOW_FAIL:
return state.setIn([action.id, action.locked ? 'requested' : 'following'], false); return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
case ACCOUNT_UNFOLLOW_REQUEST: case ACCOUNT_UNFOLLOW_REQUEST:

@ -29,12 +29,13 @@ const initialState = ImmutableMap({
notifications: ImmutableMap({ notifications: ImmutableMap({
alerts: ImmutableMap({ alerts: ImmutableMap({
follow: true, follow: false,
follow_request: false, follow_request: false,
favourite: true, favourite: false,
reblog: true, reblog: false,
mention: true, mention: false,
poll: true, poll: false,
status: false,
}), }),
quickFilter: ImmutableMap({ quickFilter: ImmutableMap({
@ -50,6 +51,7 @@ const initialState = ImmutableMap({
reblog: true, reblog: true,
mention: true, mention: true,
poll: true, poll: true,
status: true,
}), }),
sounds: ImmutableMap({ sounds: ImmutableMap({
@ -59,6 +61,7 @@ const initialState = ImmutableMap({
reblog: true, reblog: true,
mention: true, mention: true,
poll: true, poll: true,
status: true,
}), }),
}), }),

@ -0,0 +1,10 @@
import ready from '../ready';
export let assetHost = '';
ready(() => {
const cdnHost = document.querySelector('meta[name=cdn-host]');
if (cdnHost) {
assetHost = cdnHost.content || '';
}
});

@ -0,0 +1,29 @@
// Handles browser quirks, based on
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
const checkNotificationPromise = () => {
try {
Notification.requestPermission().then();
} catch(e) {
return false;
}
return true;
};
const handlePermission = (permission, callback) => {
// Whatever the user answers, we make sure Chrome stores the information
if(!('permission' in Notification)) {
Notification.permission = permission;
}
callback(Notification.permission);
};
export const requestNotificationPermission = (callback) => {
if (checkNotificationPromise()) {
Notification.requestPermission().then((permission) => handlePermission(permission, callback));
} else {
Notification.requestPermission((permission) => handlePermission(permission, callback));
}
};

@ -1,3 +1,4 @@
import './public-path';
import loadPolyfills from '../mastodon/load_polyfills'; import loadPolyfills from '../mastodon/load_polyfills';
import { start } from '../mastodon/common'; import { start } from '../mastodon/common';

@ -1,3 +1,4 @@
import './public-path';
import loadPolyfills from '../mastodon/load_polyfills'; import loadPolyfills from '../mastodon/load_polyfills';
import { start } from '../mastodon/common'; import { start } from '../mastodon/common';

@ -1 +1,2 @@
import './public-path';
import 'styles/application.scss'; import 'styles/application.scss';

@ -1,3 +1,4 @@
import './public-path';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
ready(() => { ready(() => {

@ -0,0 +1,21 @@
// Dynamically set webpack's loading path depending on a meta header, in order
// to share the same assets regardless of instance configuration.
// See https://webpack.js.org/guides/public-path/#on-the-fly
function removeOuterSlashes(string) {
return string.replace(/^\/*/, '').replace(/\/*$/, '');
}
function formatPublicPath(host = '', path = '') {
let formattedHost = removeOuterSlashes(host);
if (formattedHost && !/^http/i.test(formattedHost)) {
formattedHost = `//${formattedHost}`;
}
const formattedPath = removeOuterSlashes(path);
return `${formattedHost}/${formattedPath}/`;
}
const cdnHost = document.querySelector('meta[name=cdn-host]');
// eslint-disable-next-line camelcase, no-undef, no-unused-vars
__webpack_public_path__ = formatPublicPath(cdnHost ? cdnHost.content : '', process.env.PUBLIC_OUTPUT_PATH);

@ -1,3 +1,4 @@
import './public-path';
import loadPolyfills from '../mastodon/load_polyfills'; import loadPolyfills from '../mastodon/load_polyfills';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
import { start } from '../mastodon/common'; import { start } from '../mastodon/common';

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

Loading…
Cancel
Save