Browse Source

Merge branch 'master' into glitch-soc/merge-upstream

Conflicts:
- app/controllers/home_controller.rb
- app/controllers/shares_controller.rb
- app/javascript/packs/public.js
- app/models/status.rb
- app/serializers/initial_state_serializer.rb
- app/views/home/index.html.haml
- app/views/layouts/public.html.haml
- app/views/public_timelines/show.html.haml
- app/views/shares/show.html.haml
- app/views/tags/show.html.haml
- config/initializers/content_security_policy.rb
- config/locales/en.yml
- config/webpack/shared.js
- package.json
master
Thibaut Girka 4 weeks ago
parent
commit
1488be7d96
100 changed files with 2336 additions and 747 deletions
  1. 5
    5
      Gemfile
  2. 12
    12
      Gemfile.lock
  3. 43
    0
      app/chewy/accounts_index.rb
  4. 37
    0
      app/chewy/tags_index.rb
  5. 32
    2
      app/controllers/about_controller.rb
  6. 14
    3
      app/controllers/accounts_controller.rb
  7. 6
    5
      app/controllers/activitypub/replies_controller.rb
  8. 7
    0
      app/controllers/application_controller.rb
  9. 14
    1
      app/controllers/concerns/signature_verification.rb
  10. 2
    0
      app/controllers/follower_accounts_controller.rb
  11. 2
    0
      app/controllers/following_accounts_controller.rb
  12. 0
    16
      app/controllers/home_controller.rb
  13. 2
    0
      app/controllers/instance_actors_controller.rb
  14. 1
    1
      app/controllers/invites_controller.rb
  15. 2
    0
      app/controllers/media_proxy_controller.rb
  16. 1
    6
      app/controllers/public_timelines_controller.rb
  17. 1
    17
      app/controllers/shares_controller.rb
  18. 0
    5
      app/controllers/tags_controller.rb
  19. 21
    0
      app/helpers/application_helper.rb
  20. 10
    0
      app/javascript/mastodon/actions/app.js
  21. 19
    17
      app/javascript/mastodon/components/status.js
  22. 8
    5
      app/javascript/mastodon/containers/media_container.js
  23. 6
    79
      app/javascript/mastodon/features/compose/components/upload.js
  24. 2
    1
      app/javascript/mastodon/features/compose/components/upload_form.js
  25. 5
    4
      app/javascript/mastodon/features/compose/components/upload_progress.js
  26. 1
    5
      app/javascript/mastodon/features/compose/containers/upload_container.js
  27. 41
    0
      app/javascript/mastodon/features/ui/components/document_title.js
  28. 162
    22
      app/javascript/mastodon/features/ui/components/focal_point_modal.js
  29. 17
    1
      app/javascript/mastodon/features/ui/index.js
  30. 4
    0
      app/javascript/mastodon/features/ui/util/async-components.js
  31. 6
    5
      app/javascript/mastodon/features/video/index.js
  32. 1
    0
      app/javascript/mastodon/initial_state.js
  33. 15
    4
      app/javascript/mastodon/locales/ar.json
  34. 12
    1
      app/javascript/mastodon/locales/ast.json
  35. 12
    1
      app/javascript/mastodon/locales/bg.json
  36. 12
    1
      app/javascript/mastodon/locales/bn.json
  37. 15
    4
      app/javascript/mastodon/locales/ca.json
  38. 21
    10
      app/javascript/mastodon/locales/co.json
  39. 15
    4
      app/javascript/mastodon/locales/cs.json
  40. 12
    1
      app/javascript/mastodon/locales/cy.json
  41. 12
    1
      app/javascript/mastodon/locales/da.json
  42. 15
    4
      app/javascript/mastodon/locales/de.json
  43. 82
    6
      app/javascript/mastodon/locales/defaultMessages.json
  44. 15
    4
      app/javascript/mastodon/locales/el.json
  45. 12
    1
      app/javascript/mastodon/locales/en.json
  46. 26
    15
      app/javascript/mastodon/locales/eo.json
  47. 25
    14
      app/javascript/mastodon/locales/es.json
  48. 402
    0
      app/javascript/mastodon/locales/et.json
  49. 15
    4
      app/javascript/mastodon/locales/eu.json
  50. 16
    5
      app/javascript/mastodon/locales/fa.json
  51. 12
    1
      app/javascript/mastodon/locales/fi.json
  52. 15
    4
      app/javascript/mastodon/locales/fr.json
  53. 15
    4
      app/javascript/mastodon/locales/gl.json
  54. 12
    1
      app/javascript/mastodon/locales/he.json
  55. 12
    1
      app/javascript/mastodon/locales/hi.json
  56. 12
    1
      app/javascript/mastodon/locales/hr.json
  57. 15
    4
      app/javascript/mastodon/locales/hu.json
  58. 17
    6
      app/javascript/mastodon/locales/hy.json
  59. 12
    1
      app/javascript/mastodon/locales/id.json
  60. 12
    1
      app/javascript/mastodon/locales/io.json
  61. 18
    7
      app/javascript/mastodon/locales/it.json
  62. 15
    4
      app/javascript/mastodon/locales/ja.json
  63. 12
    1
      app/javascript/mastodon/locales/ka.json
  64. 12
    1
      app/javascript/mastodon/locales/kk.json
  65. 15
    4
      app/javascript/mastodon/locales/ko.json
  66. 12
    1
      app/javascript/mastodon/locales/lt.json
  67. 12
    1
      app/javascript/mastodon/locales/lv.json
  68. 12
    1
      app/javascript/mastodon/locales/ms.json
  69. 12
    1
      app/javascript/mastodon/locales/nl.json
  70. 12
    1
      app/javascript/mastodon/locales/no.json
  71. 15
    4
      app/javascript/mastodon/locales/oc.json
  72. 12
    1
      app/javascript/mastodon/locales/pl.json
  73. 12
    1
      app/javascript/mastodon/locales/pt-BR.json
  74. 12
    1
      app/javascript/mastodon/locales/pt.json
  75. 12
    1
      app/javascript/mastodon/locales/ro.json
  76. 18
    7
      app/javascript/mastodon/locales/ru.json
  77. 25
    14
      app/javascript/mastodon/locales/sk.json
  78. 16
    5
      app/javascript/mastodon/locales/sl.json
  79. 12
    1
      app/javascript/mastodon/locales/sq.json
  80. 12
    1
      app/javascript/mastodon/locales/sr-Latn.json
  81. 12
    1
      app/javascript/mastodon/locales/sr.json
  82. 106
    95
      app/javascript/mastodon/locales/sv.json
  83. 12
    1
      app/javascript/mastodon/locales/ta.json
  84. 12
    1
      app/javascript/mastodon/locales/te.json
  85. 21
    10
      app/javascript/mastodon/locales/th.json
  86. 12
    1
      app/javascript/mastodon/locales/tr.json
  87. 170
    159
      app/javascript/mastodon/locales/uk.json
  88. 2
    0
      app/javascript/mastodon/locales/whitelist_et.json
  89. 14
    3
      app/javascript/mastodon/locales/zh-CN.json
  90. 12
    1
      app/javascript/mastodon/locales/zh-HK.json
  91. 75
    64
      app/javascript/mastodon/locales/zh-TW.json
  92. 2
    0
      app/javascript/mastodon/reducers/index.js
  93. 21
    0
      app/javascript/mastodon/reducers/missed_updates.js
  94. 3
    1
      app/javascript/mastodon/utils/numbers.js
  95. 2
    2
      app/javascript/mastodon/utils/resize_image.js
  96. 9
    0
      app/javascript/packs/public.js
  97. 1
    1
      app/javascript/styles/mastodon/basics.scss
  98. 153
    33
      app/javascript/styles/mastodon/components.scss
  99. 67
    0
      app/javascript/styles/mastodon/tables.scss
  100. 0
    0
      app/javascript/styles/mastodon/widgets.scss

+ 5
- 5
Gemfile View File

@@ -12,7 +12,7 @@ gem 'thor', '~> 0.20'
gem 'hamlit-rails', '~> 0.2'
gem 'pg', '~> 1.1'
gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.2'
gem 'pghero', '~> 2.3'
gem 'dotenv-rails', '~> 2.7'

gem 'aws-sdk-s3', '~> 1.46', require: false
@@ -62,12 +62,12 @@ gem 'mime-types', '~> 3.2', require: 'mime/types/columnar'
gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
gem 'nokogiri', '~> 1.10'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.8'
gem 'oj', '~> 3.9'
gem 'ostatus2', '~> 2.0'
gem 'ox', '~> 2.11'
gem 'parslet'
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
gem 'pundit', '~> 2.0'
gem 'pundit', '~> 2.1'
gem 'premailer-rails'
gem 'rack-attack', '~> 6.1'
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
@@ -81,7 +81,7 @@ gem 'sidekiq', '~> 5.2'
gem 'sidekiq-scheduler', '~> 3.0'
gem 'sidekiq-unique-jobs', '~> 6.0'
gem 'sidekiq-bulk', '~>0.2.0'
gem 'simple-navigation', '~> 4.0'
gem 'simple-navigation', '~> 4.1'
gem 'simple_form', '~> 4.1'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.1.3'
@@ -134,7 +134,7 @@ group :development do
gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
gem 'rubocop', '~> 0.74', require: false
gem 'rubocop-rails', '~> 2.2', require: false
gem 'rubocop-rails', '~> 2.3', require: false
gem 'brakeman', '~> 4.6', require: false
gem 'bundler-audit', '~> 0.6', require: false


+ 12
- 12
Gemfile.lock View File

@@ -387,7 +387,7 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.8.1)
oj (3.9.0)
omniauth (1.9.0)
hashie (>= 3.4.6, < 3.7.0)
rack (>= 1.6.2, < 3)
@@ -423,9 +423,9 @@ GEM
equatable (~> 0.5.0)
tty-color (~> 0.4.0)
pg (1.1.4)
pghero (2.2.1)
activerecord
pkg-config (1.3.7)
pghero (2.3.0)
activerecord (>= 5)
pkg-config (1.3.8)
premailer (1.11.1)
addressable
css_parser (>= 1.6.0)
@@ -445,7 +445,7 @@ GEM
public_suffix (3.1.1)
puma (4.1.0)
nio4r (~> 2.0)
pundit (2.0.1)
pundit (2.1.0)
activesupport (>= 3.0.0)
raabro (1.1.6)
rack (2.0.7)
@@ -555,7 +555,7 @@ GEM
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.7)
rubocop-rails (2.2.1)
rubocop-rails (2.3.0)
rack (>= 1.1)
rubocop (>= 0.72.0)
ruby-progressbar (1.10.1)
@@ -584,7 +584,7 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 7.0)
thor (~> 0)
simple-navigation (4.0.5)
simple-navigation (4.1.0)
activesupport (>= 2.3.2)
simple_form (4.1.0)
actionpack (>= 5.0)
@@ -732,7 +732,7 @@ DEPENDENCIES
nilsimsa!
nokogiri (~> 1.10)
nsa (~> 0.2)
oj (~> 3.8)
oj (~> 3.9)
omniauth (~> 1.9)
omniauth-cas (~> 1.1)
omniauth-saml (~> 1.10)
@@ -743,7 +743,7 @@ DEPENDENCIES
parallel_tests (~> 2.29)
parslet
pg (~> 1.1)
pghero (~> 2.2)
pghero (~> 2.3)
pkg-config (~> 1.3)
posix-spawn!
premailer-rails
@@ -751,7 +751,7 @@ DEPENDENCIES
pry-byebug (~> 3.7)
pry-rails (~> 0.3)
puma (~> 4.1)
pundit (~> 2.0)
pundit (~> 2.1)
rack-attack (~> 6.1)
rack-cors (~> 1.0)
rails (~> 5.2.3)
@@ -767,13 +767,13 @@ DEPENDENCIES
rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0)
rubocop (~> 0.74)
rubocop-rails (~> 2.2)
rubocop-rails (~> 2.3)
sanitize (~> 5.0)
sidekiq (~> 5.2)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0)
sidekiq-unique-jobs (~> 6.0)
simple-navigation (~> 4.0)
simple-navigation (~> 4.1)
simple_form (~> 4.1)
simplecov (~> 0.17)
sprockets-rails (~> 3.2)

+ 43
- 0
app/chewy/accounts_index.rb View File

@@ -0,0 +1,43 @@
# frozen_string_literal: true

class AccountsIndex < Chewy::Index
settings index: { refresh_interval: '5m' }, analysis: {
analyzer: {
content: {
tokenizer: 'whitespace',
filter: %w(lowercase asciifolding cjk_width),
},

edge_ngram: {
tokenizer: 'edge_ngram',
filter: %w(lowercase asciifolding cjk_width),
},
},

tokenizer: {
edge_ngram: {
type: 'edge_ngram',
min_gram: 1,
max_gram: 15,
},
},
}

define_type ::Account.searchable.includes(:account_stat), delete_if: ->(account) { account.destroyed? || !account.searchable? } do
root date_detection: false do
field :id, type: 'long'

field :display_name, type: 'text', analyzer: 'content' do
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
end

field :acct, type: 'text', analyzer: 'content', value: ->(account) { [account.username, account.domain].compact.join('@') } do
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
end

field :following_count, type: 'long', value: ->(account) { account.following.local.count }
field :followers_count, type: 'long', value: ->(account) { account.followers.local.count }
field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }
end
end
end

+ 37
- 0
app/chewy/tags_index.rb View File

@@ -0,0 +1,37 @@
# frozen_string_literal: true

class TagsIndex < Chewy::Index
settings index: { refresh_interval: '15m' }, analysis: {
analyzer: {
content: {
tokenizer: 'keyword',
filter: %w(lowercase asciifolding cjk_width),
},

edge_ngram: {
tokenizer: 'edge_ngram',
filter: %w(lowercase asciifolding cjk_width),
},
},

tokenizer: {
edge_ngram: {
type: 'edge_ngram',
min_gram: 2,
max_gram: 15,
},
},
}

define_type ::Tag.listable, delete_if: ->(tag) { tag.destroyed? || !tag.listable? } do
root date_detection: false do
field :name, type: 'text', analyzer: 'content' do
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
end

field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? }
field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day[:accounts].to_i } }
field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at }
end
end
end

+ 32
- 2
app/controllers/about_controller.rb View File

@@ -4,10 +4,12 @@ class AboutController < ApplicationController
before_action :set_pack
layout 'public'

before_action :require_open_federation!, only: [:show, :more]
before_action :require_open_federation!, only: [:show, :more, :blocks]
before_action :check_blocklist_enabled, only: [:blocks]
before_action :authenticate_user!, only: [:blocks], if: :blocklist_account_required?
before_action :set_body_classes, only: :show
before_action :set_instance_presenter
before_action :set_expires_in
before_action :set_expires_in, only: [:show, :more, :terms]

skip_before_action :require_functional!, only: [:more, :terms]

@@ -19,12 +21,40 @@ class AboutController < ApplicationController

def terms; end

def blocks
@show_rationale = Setting.show_domain_blocks_rationale == 'all'
@show_rationale |= Setting.show_domain_blocks_rationale == 'users' && !current_user.nil? && current_user.functional?
@blocks = DomainBlock.with_user_facing_limitations.order('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), reject_media, domain').to_a
end

private

def require_open_federation!
not_found if whitelist_mode?
end

def check_blocklist_enabled
not_found if Setting.show_domain_blocks == 'disabled'
end

def blocklist_account_required?
Setting.show_domain_blocks == 'users'
end

def block_severity_text(block)
if block.severity == 'suspend'
I18n.t('domain_blocks.suspension')
else
limitations = []
limitations << I18n.t('domain_blocks.media_block') if block.reject_media?
limitations << I18n.t('domain_blocks.silence') if block.severity == 'silence'
limitations.join(', ')
end
end

helper_method :block_severity_text
helper_method :public_fetch_mode?

def new_user
User.new.tap do |user|
user.build_account

+ 14
- 3
app/controllers/accounts_controller.rb View File

@@ -19,6 +19,7 @@ class AccountsController < ApplicationController

@pinned_statuses = []
@endorsed_accounts = @account.endorsed_accounts.to_a.sample(4)
@featured_hashtags = @account.featured_tags.order(statuses_count: :desc)

if current_account && @account.blocking?(current_account)
@statuses = []
@@ -28,6 +29,7 @@ class AccountsController < ApplicationController
@pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
@statuses = filtered_status_page(params)
@statuses = cache_collection(@statuses, Status)
@rss_url = rss_url

unless @statuses.empty?
@older_url = older_url if @statuses.last.id > filtered_statuses.last.id
@@ -38,8 +40,9 @@ class AccountsController < ApplicationController
format.rss do
expires_in 0, public: true

@statuses = cache_collection(default_statuses.without_reblogs.without_replies.limit(PAGE_SIZE), Status)
render xml: RSS::AccountSerializer.render(@account, @statuses)
@statuses = filtered_statuses.without_reblogs.without_replies.limit(PAGE_SIZE)
@statuses = cache_collection(@statuses, Status)
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
end

format.json do
@@ -97,6 +100,14 @@ class AccountsController < ApplicationController
params[:username]
end

def rss_url
if tag_requested?
short_account_tag_url(@account, params[:tag], format: 'rss')
else
short_account_url(@account, format: 'rss')
end
end

def older_url
pagination_url(max_id: @statuses.last.id)
end
@@ -126,7 +137,7 @@ class AccountsController < ApplicationController
end

def tag_requested?
request.path.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end

def filtered_status_page(params)

+ 6
- 5
app/controllers/activitypub/replies_controller.rb View File

@@ -27,7 +27,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
end

def set_replies
@replies = page_params[:other_accounts] ? Status.where.not(account_id: @account.id) : @account.statuses
@replies = page_params[:only_other_accounts] ? Status.where.not(account_id: @account.id) : @account.statuses
@replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted])
@replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
end
@@ -38,7 +38,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
type: :unordered,
part_of: account_status_replies_url(@account, @status),
next: next_page,
items: @replies.map { |status| status.local ? status : status.id }
items: @replies.map { |status| status.local ? status : status.uri }
)

return page if page_requested?
@@ -55,16 +55,17 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
end

def next_page
only_other_accounts = !(@replies&.last&.account_id == @account.id && @replies.size == DESCENDANTS_LIMIT)
account_status_replies_url(
@account,
@status,
page: true,
min_id: @replies&.last&.id,
other_accounts: !(@replies&.last&.account_id == @account.id && @replies.size == DESCENDANTS_LIMIT)
min_id: only_other_accounts && !page_params[:only_other_accounts] ? nil : @replies&.last&.id,
only_other_accounts: only_other_accounts
)
end

def page_params
params_slice(:other_accounts, :min_id).merge(page: true)
params_slice(:only_other_accounts, :min_id).merge(page: true)
end
end

+ 7
- 0
app/controllers/application_controller.rb View File

@@ -26,10 +26,13 @@ class ApplicationController < ActionController::Base
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
rescue_from ActionController::UnknownFormat, with: :not_acceptable
rescue_from Mastodon::NotPermittedError, with: :forbidden
rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error

before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
before_action :require_functional!, if: :user_signed_in?

skip_before_action :verify_authenticity_token, only: :raise_not_found

def raise_not_found
raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}"
end
@@ -163,6 +166,10 @@ class ApplicationController < ActionController::Base
respond_with_error(406)
end

def internal_server_error
respond_with_error(500)
end

def single_user_mode?
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists?
end

+ 14
- 1
app/controllers/concerns/signature_verification.rb View File

@@ -23,6 +23,19 @@ module SignatureVerification
@signature_verification_failure_code || 401
end

def signature_key_id
raw_signature = request.headers['Signature']
signature_params = {}

raw_signature.split(',').each do |part|
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
next if parsed_parts.nil? || parsed_parts.size != 3
signature_params[parsed_parts[1]] = parsed_parts[2]
end

signature_params['keyId']
end

def signed_request_account
return @signed_request_account if defined?(@signed_request_account)

@@ -154,7 +167,7 @@ module SignatureVerification
.with_fallback { nil }
.with_threshold(1)
.with_cool_off_time(5.minutes.seconds)
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) ? handle.call(error) : raise(error) }
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
.run
end


+ 2
- 0
app/controllers/follower_accounts_controller.rb View File

@@ -7,6 +7,8 @@ class FollowerAccountsController < ApplicationController
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers

skip_around_action :set_locale, if: -> { request.format == :json }

def index
respond_to do |format|
format.html do

+ 2
- 0
app/controllers/following_accounts_controller.rb View File

@@ -7,6 +7,8 @@ class FollowingAccountsController < ApplicationController
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers

skip_around_action :set_locale, if: -> { request.format == :json }

def index
respond_to do |format|
format.html do

+ 0
- 16
app/controllers/home_controller.rb View File

@@ -5,7 +5,6 @@ class HomeController < ApplicationController

before_action :set_pack
before_action :set_referrer_policy_header
before_action :set_initial_state_json

def index
@body_classes = 'app-body'
@@ -45,21 +44,6 @@ class HomeController < ApplicationController
use_pack 'home'
end

def set_initial_state_json
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
end

def initial_state_params
{
settings: Web::Setting.find_by(user: current_user)&.data || {},
push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account,
token: current_session.token,
admin: Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')),
}
end

def default_redirect_path
if request.path.start_with?('/web') || whitelist_mode?
new_user_session_path

+ 2
- 0
app/controllers/instance_actors_controller.rb View File

@@ -3,6 +3,8 @@
class InstanceActorsController < ApplicationController
include AccountControllerConcern

skip_around_action :set_locale

def show
expires_in 10.minutes, public: true
render json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to

+ 1
- 1
app/controllers/invites_controller.rb View File

@@ -48,7 +48,7 @@ class InvitesController < ApplicationController
end

def resource_params
params.require(:invite).permit(:max_uses, :expires_in, :autofollow)
params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment)
end

def set_body_classes

+ 2
- 0
app/controllers/media_proxy_controller.rb View File

@@ -7,6 +7,8 @@ class MediaProxyController < ApplicationController

before_action :authenticate_user!, if: :whitelist_mode?

rescue_from ActiveRecord::RecordInvalid, with: :not_found

def show
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?

+ 1
- 6
app/controllers/public_timelines_controller.rb View File

@@ -9,12 +9,7 @@ class PublicTimelinesController < ApplicationController
before_action :set_body_classes
before_action :set_instance_presenter

def show
@initial_state_json = ActiveModelSerializers::SerializableResource.new(
InitialStatePresenter.new(settings: { known_fediverse: Setting.show_known_fediverse_at_about_page }, token: current_session&.token),
serializer: InitialStateSerializer
).to_json
end
def show; end

private


+ 1
- 17
app/controllers/shares_controller.rb View File

@@ -7,26 +7,10 @@ class SharesController < ApplicationController
before_action :set_pack
before_action :set_body_classes

def show
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
end
def show; end

private

def initial_state_params
text = [params[:title], params[:text], params[:url]].compact.join(' ')

{
settings: Web::Setting.find_by(user: current_user)&.data || {},
push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account,
token: current_session.token,
admin: Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')),
text: text,
}
end

def set_pack
use_pack 'share'
end

+ 0
- 5
app/controllers/tags_controller.rb View File

@@ -18,11 +18,6 @@ class TagsController < ApplicationController
format.html do
use_pack 'about'
expires_in 0, public: true

@initial_state_json = ActiveModelSerializers::SerializableResource.new(
InitialStatePresenter.new(settings: {}, token: current_session&.token),
serializer: InitialStateSerializer
).to_json
end

format.rss do

+ 21
- 0
app/helpers/application_helper.rb View File

@@ -123,4 +123,25 @@ module ApplicationHelper
text = word_wrap(text, line_width: line_width - 2, break_sequence: break_sequence)
text.split("\n").map { |line| '> ' + line }.join("\n")
end

def render_initial_state
state_params = {
settings: {
known_fediverse: Setting.show_known_fediverse_at_about_page,
},

text: [params[:title], params[:text], params[:url]].compact.join(' '),
}

if user_signed_in?
state_params[:settings] = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {})
state_params[:push_subscription] = current_account.user.web_push_subscription(current_session)
state_params[:current_account] = current_account
state_params[:token] = current_session.token
state_params[:admin] = Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, ''))
end

json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json
content_tag(:script, json_escape(json).html_safe, id: 'initial-state', type: 'application/json')
end
end

+ 10
- 0
app/javascript/mastodon/actions/app.js View File

@@ -0,0 +1,10 @@
export const APP_FOCUS = 'APP_FOCUS';
export const APP_UNFOCUS = 'APP_UNFOCUS';

export const focusApp = () => ({
type: APP_FOCUS,
});

export const unfocusApp = () => ({
type: APP_UNFOCUS,
});

+ 19
- 17
app/javascript/mastodon/components/status.js View File

@@ -278,12 +278,27 @@ class Status extends ImmutablePureComponent {
return null;
}

const handlers = this.props.muted ? {} : {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
};

if (hidden) {
return (
<div ref={this.handleRef}>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')}
</div>
<HotKeys handlers={handlers}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')}
</div>
</HotKeys>
);
}

@@ -394,19 +409,6 @@ class Status extends ImmutablePureComponent {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}

const handlers = this.props.muted ? {} : {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
};

return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>

+ 8
- 5
app/javascript/mastodon/containers/media_container.js View File

@@ -7,6 +7,7 @@ import MediaGallery from '../components/media_gallery';
import Video from '../features/video';
import Card from '../features/status/components/card';
import Poll from 'mastodon/components/poll';
import Hashtag from 'mastodon/components/hashtag';
import ModalRoot from '../components/modal_root';
import { getScrollbarWidth } from '../features/ui/components/modal_root';
import MediaModal from '../features/ui/components/media_modal';
@@ -15,7 +16,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
const { localeData, messages } = getLocale();
addLocaleData(localeData);

const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll };
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag };

export default class MediaContainer extends PureComponent {

@@ -62,12 +63,13 @@ export default class MediaContainer extends PureComponent {
{[].map.call(components, (component, i) => {
const componentName = component.getAttribute('data-component');
const Component = MEDIA_COMPONENTS[componentName];
const { media, card, poll, ...props } = JSON.parse(component.getAttribute('data-props'));
const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props'));

Object.assign(props, {
...(media ? { media: fromJS(media) } : {}),
...(card ? { card: fromJS(card) } : {}),
...(poll ? { poll: fromJS(poll) } : {}),
...(media ? { media: fromJS(media) } : {}),
...(card ? { card: fromJS(card) } : {}),
...(poll ? { poll: fromJS(poll) } : {}),
...(hashtag ? { hashtag: fromJS(hashtag) } : {}),

...(componentName === 'Video' ? {
onOpenVideo: this.handleOpenVideo,
@@ -81,6 +83,7 @@ export default class MediaContainer extends PureComponent {
component,
);
})}

<ModalRoot onClose={this.handleCloseMedia}>
{this.state.media && (
<MediaModal

+ 6
- 79
app/javascript/mastodon/features/compose/components/upload.js View File

@@ -4,16 +4,11 @@ import PropTypes from 'prop-types';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';

const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
});

export default @injectIntl
class Upload extends ImmutablePureComponent {
export default class Upload extends ImmutablePureComponent {

static contextTypes = {
router: PropTypes.object,
@@ -21,30 +16,10 @@ class Upload extends ImmutablePureComponent {

static propTypes = {
media: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onUndo: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
onOpenFocalPoint: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};

state = {
hovered: false,
focused: false,
dirtyDescription: null,
};

handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
}

handleSubmit = () => {
this.handleInputBlur();
this.props.onSubmit(this.context.router.history);
}

handleUndoClick = e => {
e.stopPropagation();
this.props.onUndo(this.props.media.get('id'));
@@ -55,69 +30,21 @@ class Upload extends ImmutablePureComponent {
this.props.onOpenFocalPoint(this.props.media.get('id'));
}

handleInputChange = e => {
this.setState({ dirtyDescription: e.target.value });
}

handleMouseEnter = () => {
this.setState({ hovered: true });
}

handleMouseLeave = () => {
this.setState({ hovered: false });
}

handleInputFocus = () => {
this.setState({ focused: true });
}

handleClick = () => {
this.setState({ focused: true });
}

handleInputBlur = () => {
const { dirtyDescription } = this.state;

this.setState({ focused: false, dirtyDescription: null });

if (dirtyDescription !== null) {
this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
}
}

render () {
const { intl, media } = this.props;
const active = this.state.hovered || this.state.focused;
const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
const { media } = this.props;
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;

return (
<div className='compose-form__upload' tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
<div className='compose-form__upload' tabIndex='0' role='button'>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className={classNames('compose-form__upload__actions', { active })}>
<div className={classNames('compose-form__upload__actions', { active: true })}>
<button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
{media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
</div>

<div className={classNames('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>

<textarea
placeholder={intl.formatMessage(messages.description)}
value={description}
maxLength={420}
onFocus={this.handleInputFocus}
onChange={this.handleInputChange}
onBlur={this.handleInputBlur}
onKeyDown={this.handleKeyDown}
/>
</label>
<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
</div>
</div>
)}

+ 2
- 1
app/javascript/mastodon/features/compose/components/upload_form.js View File

@@ -4,6 +4,7 @@ import UploadProgressContainer from '../containers/upload_progress_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import UploadContainer from '../containers/upload_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import { FormattedMessage } from 'react-intl';

export default class UploadForm extends ImmutablePureComponent {

@@ -16,7 +17,7 @@ export default class UploadForm extends ImmutablePureComponent {

return (
<div className='compose-form__upload-wrapper'>
<UploadProgressContainer />
<UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} />

<div className='compose-form__uploads-wrapper'>
{mediaIds.map(id => (

+ 5
- 4
app/javascript/mastodon/features/compose/components/upload_progress.js View File

@@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { FormattedMessage } from 'react-intl';
import Icon from 'mastodon/components/icon';

export default class UploadProgress extends React.PureComponent {
@@ -10,10 +9,12 @@ export default class UploadProgress extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
progress: PropTypes.number,
icon: PropTypes.string.isRequired,
message: PropTypes.node.isRequired,
};

render () {
const { active, progress } = this.props;
const { active, progress, icon, message } = this.props;

if (!active) {
return null;
@@ -22,11 +23,11 @@ export default class UploadProgress extends React.PureComponent {
return (
<div className='upload-progress'>
<div className='upload-progress__icon'>
<Icon id='upload' />
<Icon id={icon} />
</div>

<div className='upload-progress__message'>
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
{message}

<div className='upload-progress__backdrop'>
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>

+ 1
- 5
app/javascript/mastodon/features/compose/containers/upload_container.js View File

@@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import Upload from '../components/upload';
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
import { undoUploadCompose } from '../../../actions/compose';
import { openModal } from '../../../actions/modal';
import { submitCompose } from '../../../actions/compose';

@@ -14,10 +14,6 @@ const mapDispatchToProps = dispatch => ({
dispatch(undoUploadCompose(id));
},

onDescriptionChange: (id, description) => {
dispatch(changeUploadCompose(id, { description }));
},

onOpenFocalPoint: id => {
dispatch(openModal('FOCAL_POINT', { id }));
},

+ 41
- 0
app/javascript/mastodon/features/ui/components/document_title.js View File

@@ -0,0 +1,41 @@
import { PureComponent } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { title } from 'mastodon/initial_state';

const mapStateToProps = state => ({
unread: state.getIn(['missed_updates', 'unread']),
});

export default @connect(mapStateToProps)
class DocumentTitle extends PureComponent {

static propTypes = {
unread: PropTypes.number.isRequired,
};

componentDidMount () {
this._sideEffects();
}

componentDidUpdate() {
this._sideEffects();
}

_sideEffects () {
const { unread } = this.props;

if (unread > 99) {
document.title = `(*) ${title}`;
} else if (unread > 0) {
document.title = `(${unread}) ${title}`;
} else {
document.title = title;
}
}

render () {
return null;
}

}

+ 162
- 22
app/javascript/mastodon/features/ui/components/focal_point_modal.js View File

@@ -1,11 +1,26 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import ImageLoader from './image_loader';
import classNames from 'classnames';
import { changeUploadCompose } from '../../../actions/compose';
import { getPointerPosition } from '../../video';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'mastodon/components/icon_button';
import Button from 'mastodon/components/button';
import Video from 'mastodon/features/video';
import Textarea from 'react-textarea-autosize';
import UploadProgress from 'mastodon/features/compose/components/upload_progress';
import CharacterCounter from 'mastodon/features/compose/components/character_counter';
import { length } from 'stringz';
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';

const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
});

const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
@@ -13,17 +28,26 @@ const mapStateToProps = (state, { id }) => ({

const mapDispatchToProps = (dispatch, { id }) => ({

onSave: (x, y) => {
dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
onSave: (description, x, y) => {
dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
},

});

const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
.replace(/\n/g, ' ')
.replace(/\*\*\*\*\*\*/g, '\n\n');

const assetHost = process.env.CDN_HOST || '';

export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class FocalPointModal extends ImmutablePureComponent {

static propTypes = {
media: ImmutablePropTypes.map.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};

state = {
@@ -32,6 +56,9 @@ class FocalPointModal extends ImmutablePureComponent {
focusX: 0,
focusY: 0,
dragging: false,
description: '',
dirty: false,
progress: 0,
};

componentWillMount () {
@@ -57,6 +84,14 @@ class FocalPointModal extends ImmutablePureComponent {
this.setState({ dragging: true });
}

handleTouchStart = e => {
document.addEventListener('touchmove', this.handleMouseMove);
document.addEventListener('touchend', this.handleTouchEnd);

this.updatePosition(e);
this.setState({ dragging: true });
}

handleMouseMove = e => {
this.updatePosition(e);
}
@@ -66,7 +101,13 @@ class FocalPointModal extends ImmutablePureComponent {
document.removeEventListener('mouseup', this.handleMouseUp);

this.setState({ dragging: false });
this.props.onSave(this.state.focusX, this.state.focusY);
}

handleTouchEnd = () => {
document.removeEventListener('touchmove', this.handleMouseMove);
document.removeEventListener('touchend', this.handleTouchEnd);

this.setState({ dragging: false });
}

updatePosition = e => {
@@ -74,46 +115,145 @@ class FocalPointModal extends ImmutablePureComponent {
const focusX = (x - .5) * 2;
const focusY = (y - .5) * -2;

this.setState({ x, y, focusX, focusY });
this.setState({ x, y, focusX, focusY, dirty: true });
}

updatePositionFromMedia = media => {
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const description = media.get('description') || '';

if (focusX && focusY) {
const x = (focusX / 2) + .5;
const y = (focusY / -2) + .5;

this.setState({ x, y, focusX, focusY });
this.setState({
x,
y,
focusX,
focusY,
description,
dirty: false,
});
} else {
this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
this.setState({
x: 0.5,
y: 0.5,
focusX: 0,
focusY: 0,
description,
dirty: false,
});
}
}

handleChange = e => {
this.setState({ description: e.target.value, dirty: true });
}

handleSubmit = () => {
this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
this.props.onClose();
}

setRef = c => {
this.node = c;
}

render () {
handleTextDetection = () => {
const { media } = this.props;
const { x, y, dragging } = this.state;

this.setState({ detecting: true });

fetchTesseract().then(({ TesseractWorker }) => {
const worker = new TesseractWorker({
workerPath: `${assetHost}/packs/ocr/worker.min.js`,
corePath: `${assetHost}/packs/ocr/tesseract-core.wasm.js`,
langPath: `${assetHost}/ocr/lang-data`,
});

worker.recognize(media.get('url'))
.progress(({ progress }) => this.setState({ progress }))
.finally(() => worker.terminate())
.then(({ text }) => this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }))
.catch(() => this.setState({ detecting: false }));
}).catch(() => this.setState({ detecting: false }));
}

render () {
const { media, intl, onClose } = this.props;
const { x, y, dragging, description, dirty, detecting, progress } = this.state;

const width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || null;
const focals = ['image', 'gifv'].includes(media.get('type'));

const previewRatio = 16/9;
const previewWidth = 200;
const previewHeight = previewWidth / previewRatio;

return (
<div className='modal-root__modal video-modal focal-point-modal'>
<div className={classNames('focal-point', { dragging })} ref={this.setRef}>
<ImageLoader
previewSrc={media.get('preview_url')}
src={media.get('url')}
width={width}
height={height}
/>

<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
<div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
<div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
<div className='report-modal__target'>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
<FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
</div>

<div className='report-modal__container'>
<div className='report-modal__comment'>
{focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}

<label className='setting-text-label' htmlFor='upload-modal__description'><FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' /></label>

<div className='setting-text__wrapper'>
<Textarea
id='upload-modal__description'
className='setting-text light'
value={detecting ? '…' : description}
onChange={this.handleChange}
disabled={detecting}
autoFocus
/>

<div className='setting-text__modifiers'>
<UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={<FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />} />
</div>
</div>

<div className='setting-text__toolbar'>
<button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
<CharacterCounter max={420} text={detecting ? '' : description} />
</div>

<Button disabled={!dirty || detecting || length(description) > 420} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
</div>

<div className='focal-point-modal__content'>
{focals && (
<div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
{media.get('type') === 'image' && <img src={media.get('url')} width={width} height={height} alt='' />}
{media.get('type') === 'gifv' && <video src={media.get('url')} width={width} height={height} loop muted autoPlay />}

<div className='focal-point__preview'>
<strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
<div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
</div>

<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
<div className='focal-point__overlay' />
</div>
)}

{['audio', 'video'].includes(media.get('type')) && (
<Video
preview={media.get('preview_url')}
blurhash={media.get('blurhash')}
src={media.get('url')}
detailed
editable
/>
)}
</div>
</div>
</div>
);

+ 17
- 1
app/javascript/mastodon/features/ui/index.js View File

@@ -15,9 +15,11 @@ import { expandHomeTimeline } from '../../actions/timelines';
import { expandNotifications } from '../../actions/notifications';
import { fetchFilters } from '../../actions/filters';
import { clearHeight } from '../../actions/height_cache';
import { focusApp, unfocusApp } from 'mastodon/actions/app';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container';
import DocumentTitle from './components/document_title';
import {
Compose,
Status,
@@ -226,7 +228,7 @@ class UI extends React.PureComponent {
draggingOver: false,
};

handleBeforeUnload = (e) => {
handleBeforeUnload = e => {
const { intl, isComposing, hasComposingText, hasMediaAttachments } = this.props;

if (isComposing && (hasComposingText || hasMediaAttachments)) {
@@ -237,6 +239,14 @@ class UI extends React.PureComponent {
}
}

handleWindowFocus = () => {
this.props.dispatch(focusApp());
}

handleWindowBlur = () => {
this.props.dispatch(unfocusApp());
}

handleLayoutChange = () => {
// The cached heights are no longer accurate, invalidate
this.props.dispatch(clearHeight());
@@ -314,6 +324,8 @@ class UI extends React.PureComponent {
}

componentWillMount () {
window.addEventListener('focus', this.handleWindowFocus, false);
window.addEventListener('blur', this.handleWindowBlur, false);
window.addEventListener('beforeunload', this.handleBeforeUnload, false);

document.addEventListener('dragenter', this.handleDragEnter, false);
@@ -343,7 +355,10 @@ class UI extends React.PureComponent {
}

componentWillUnmount () {
window.removeEventListener('focus', this.handleWindowFocus);
window.removeEventListener('blur', this.handleWindowBlur);
window.removeEventListener('beforeunload', this.handleBeforeUnload);

document.removeEventListener('dragenter', this.handleDragEnter);
document.removeEventListener('dragover', this.handleDragOver);
document.removeEventListener('drop', this.handleDrop);
@@ -502,6 +517,7 @@ class UI extends React.PureComponent {
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
<DocumentTitle />
</div>
</HotKeys>
);

+ 4
- 0
app/javascript/mastodon/features/ui/util/async-components.js View File

@@ -133,3 +133,7 @@ export function ListAdder () {
export function Search () {
return import(/*webpackChunkName: "features/search" */'../../search');
}

export function Tesseract () {
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
}

+ 6
- 5
app/javascript/mastodon/features/video/index.js View File

@@ -101,6 +101,7 @@ class Video extends React.PureComponent {
onCloseVideo: PropTypes.func,
detailed: PropTypes.bool,
inline: PropTypes.bool,
editable: PropTypes.bool,
cacheWidth: PropTypes.func,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
@@ -375,7 +376,7 @@ class Video extends React.PureComponent {
}

render () {
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link } = this.props;
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable } = this.props;
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
const progress = (currentTime / duration) * 100;

@@ -413,7 +414,7 @@ class Video extends React.PureComponent {
return (
<div
role='menuitem'
className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen })}
className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable })}
style={playerStyle}
ref={this.setPlayerRef}
onMouseEnter={this.handleMouseEnter}
@@ -423,7 +424,7 @@ class Video extends React.PureComponent {
>
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />

{revealed && <video
{(revealed || editable) && <video
ref={this.setVideoRef}
src={src}
poster={preview}
@@ -445,7 +446,7 @@ class Video extends React.PureComponent {
onVolumeChange={this.handleVolumeChange}
/>}

<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
<span className='spoiler-button__overlay__label'>{warning}</span>
</button>
@@ -489,7 +490,7 @@ class Video extends React.PureComponent {
</div>

<div className='video-player__buttons right'>
{!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
{(!onCloseVideo && !editable) && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
{(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
{onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
<button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>

+ 1
- 0
app/javascript/mastodon/initial_state.js View File

@@ -24,5 +24,6 @@ export const forceSingleColumn = !getMeta('advanced_layout');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const showTrends = getMeta('trends');
export const title = getMeta('title');

export default initialState;

+ 15
- 4
app/javascript/mastodon/locales/ar.json View File

@@ -4,6 +4,7 @@
"account.block": "حظر @{name}",
"account.block_domain": "إخفاء كل شيئ قادم من اسم النطاق {domain}",
"account.blocked": "محظور",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "رسالة خاصة إلى @{name}",
"account.domain_blocked": "النطاق مخفي",
"account.edit_profile": "تعديل الملف الشخصي",
@@ -37,6 +38,7 @@
"account.unmute_notifications": "إلغاء كتم إخطارات @{name}",
"alert.unexpected.message": "لقد طرأ هناك خطأ غير متوقّع.",
"alert.unexpected.title": "المعذرة!",
"autosuggest_hashtag.per_week": "{count} per week",
"boost_modal.combo": "يمكنك/ي ضغط {combo} لتخطّي هذه في المرّة القادمة",
"bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
"bundle_column_error.retry": "إعادة المحاولة",
@@ -156,7 +158,7 @@
"home.column_settings.basic": "أساسية",
"home.column_settings.show_reblogs": "عرض الترقيات",
"home.column_settings.show_replies": "عرض الردود",
"home.column_settings.update_live": "Update in real-time",
"home.column_settings.update_live": "تحديث في الوقت الحالي",
"intervals.full.days": "{number, plural, one {# يوم} other {# أيام}}",
"intervals.full.hours": "{number, plural, one {# ساعة} other {# ساعات}}",
"intervals.full.minutes": "{number, plural, one {# دقيقة} other {# دقائق}}",
@@ -251,10 +253,11 @@
"navigation_bar.profile_directory": "دليل المستخدِمين",
"navigation_bar.public_timeline": "الخيط العام الموحد",
"navigation_bar.security": "الأمان",
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
"notification.favourite": "أُعجِب {name} بمنشورك",
"notification.follow": "{name} يتابعك",
"notification.mention": "{name} ذكرك",
"notification.poll": "A poll you have voted in has ended",
"notification.poll": "لقد إنتها تصويت شاركت فيه",
"notification.reblog": "{name} قام بترقية تبويقك",
"notifications.clear": "امسح الإخطارات",
"notifications.clear_confirmation": "أمتأكد من أنك تود مسح جل الإخطارات الخاصة بك و المتلقاة إلى حد الآن ؟",
@@ -316,7 +319,7 @@
"search_results.accounts": "أشخاص",
"search_results.hashtags": "الوُسوم",
"search_results.statuses": "التبويقات",
"search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
"search_results.statuses_fts_disabled": "البحث في التبويقات عن طريق المحتوى ليس مفعل في خادم ماستدون هذا.",
"search_results.total": "{count, number} {count, plural, one {result} و {results}}",
"status.admin_account": "افتح الواجهة الإدارية لـ @{name}",
"status.admin_status": "افتح هذا المنشور على واجهة الإشراف",
@@ -370,14 +373,22 @@
"time_remaining.moments": "لحظات متبقية",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} آخرون {people}} يتحدثون",
"trends.refresh": "Refresh",
"ui.beforeunload": "سوف تفقد مسودتك إن تركت ماستدون.",
"upload_area.title": "اسحب ثم أفلت للرفع",
"upload_button.label": "إضافة وسائط ({formats})",
"upload_error.limit": "لقد تم بلوغ الحد الأقصى المسموح به لإرسال الملفات.",
"upload_error.poll": "لا يمكن إدراج ملفات في استطلاعات الرأي.",
"upload_form.description": "وصف للمعاقين بصريا",
"upload_form.focus": "قص",
"upload_form.edit": "Edit",
"upload_form.undo": "حذف",
"upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply",
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
"upload_modal.detect_text": "Detect text from picture",
"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.preview_label": "Preview ({ratio})",
"upload_progress.label": "يرفع...",
"video.close": "إغلاق الفيديو",
"video.exit_fullscreen": "الخروج من وضع الشاشة المليئة",

+ 12
- 1
app/javascript/mastodon/locales/ast.json View File

@@ -4,6 +4,7 @@
"account.block": "Bloquiar a @{name}",
"account.block_domain": "Anubrir tolo de {domain}",
"account.blocked": "Blocked",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "Unviar un mensaxe direutu a @{name}",
"account.domain_blocked": "Dominiu anubríu",
"account.edit_profile": "Editar el perfil",
@@ -37,6 +38,7 @@
"account.unmute_notifications": "Unmute notifications from @{name}",
"alert.unexpected.message": "Asocedió un fallu inesperáu.",
"alert.unexpected.title": "¡Ups!",
"autosuggest_hashtag.per_week": "{count} per week",
"boost_modal.combo": "Pues primir {combo} pa saltar esto la próxima vegada",
"bundle_column_error.body": "Something went wrong while loading this component.",
"bundle_column_error.retry": "Try again",
@@ -251,6 +253,7 @@
"navigation_bar.profile_directory": "Profile directory",
"navigation_bar.public_timeline": "Llinia temporal federada",
"navigation_bar.security": "Seguranza",
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
"notification.favourite": "{name} favourited your status",
"notification.follow": "{name} siguióte",
"notification.mention": "{name} mentóte",
@@ -370,14 +373,22 @@
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.refresh": "Refresh",
"ui.beforeunload": "El borrador va perdese si coles de Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.description": "Descripción pa discapacitaos visuales",
"upload_form.focus": "Crop",
"upload_form.edit": "Edit",
"upload_form.undo": "Desaniciar",
"upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply",
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
"upload_modal.detect_text": "Detect text from picture",
"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.preview_label": "Preview ({ratio})",
"upload_progress.label": "Xubiendo...",
"video.close": "Zarrar el videu",
"video.exit_fullscreen": "Colar de la pantalla completa",

+ 12
- 1
app/javascript/mastodon/locales/bg.json View File

@@ -4,6 +4,7 @@
"account.block": "Блокирай",
"account.block_domain": "Hide everything from {domain}",
"account.blocked": "Blocked",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "Direct Message @{name}",
"account.domain_blocked": "Domain hidden",
"account.edit_profile": "Редактирай профила си",
@@ -37,6 +38,7 @@
"account.unmute_notifications": "Unmute notifications from @{name}",
"alert.unexpected.message": "An unexpected error occurred.",
"alert.unexpected.title": "Oops!",
"autosuggest_hashtag.per_week": "{count} per week",
"boost_modal.combo": "You can press {combo} to skip this next time",
"bundle_column_error.body": "Something went wrong while loading this component.",
"bundle_column_error.retry": "Try again",
@@ -251,6 +253,7 @@
"navigation_bar.profile_directory": "Profile directory",
"navigation_bar.public_timeline": "Публичен канал",
"navigation_bar.security": "Security",
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
"notification.favourite": "{name} хареса твоята публикация",
"notification.follow": "{name} те последва",
"notification.mention": "{name} те спомена",
@@ -370,14 +373,22 @@
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.refresh": "Refresh",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Добави медия",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.description": "Describe for the visually impaired",
"upload_form.focus": "Crop",
"upload_form.edit": "Edit",
"upload_form.undo": "Отмяна",
"upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply",
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
"upload_modal.detect_text": "Detect text from picture",
"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.preview_label": "Preview ({ratio})",
"upload_progress.label": "Uploading...",
"video.close": "Close video",
"video.exit_fullscreen": "Exit full screen",

+ 12
- 1
app/javascript/mastodon/locales/bn.json View File

@@ -4,6 +4,7 @@
"account.block": "@{name} কে বন্ধ করুন",
"account.block_domain": "{domain} থেকে সব সরিয়ে ফেলুন",
"account.blocked": "বন্ধ করা হয়েছে",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "@{name} এর কাছে সরকারি লেখা পাঠাতে",
"account.domain_blocked": "ওয়েবসাইট সরিয়ে ফেলা হয়েছে",
"account.edit_profile": "নিজের পাতা সম্পাদনা করতে",
@@ -37,6 +38,7 @@
"account.unmute_notifications": "@{name}র প্রজ্ঞাপন দেওয়ার অনুমতি দিন",
"alert.unexpected.message": "অপ্রত্যাশিত একটি সমস্যা হয়েছে।",
"alert.unexpected.title": "ওহো!",
"autosuggest_hashtag.per_week": "{count} per week",
"boost_modal.combo": "পরেরবার আপনি {combo} চাপ দিলে এটার শেষে চলে যেতে পারবেন",
"bundle_column_error.body": "এই অংশটি দেখতে যেয়ে কোনো সমস্যা হয়েছে।",
"bundle_column_error.retry": "আবার চেষ্টা করুন",
@@ -251,6 +253,7 @@
"navigation_bar.profile_directory": "নিজস্ব পাতার তালিকা",
"navigation_bar.public_timeline": "যুক্তবিশ্বের সময়রেখা",
"navigation_bar.security": "নিরাপত্তা",
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
"notification.favourite": "{name} আপনার কার্যক্রম পছন্দ করেছেন",
"notification.follow": "{name} আপনাকে অনুসরণ করেছেন",
"notification.mention": "{name} আপনাকে উল্লেখ করেছেন",
@@ -370,14 +373,22 @@
"time_remaining.moments": "সময় বাকি আছে",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} বাকি আছে",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} কথা বলছে",
"trends.refresh": "Refresh",
"ui.beforeunload": "যে পর্যন্ত এটা লেখা হয়েছে, মাস্টাডন থেকে চলে গেলে এটা মুছে যাবে।",
"upload_area.title": "টেনে এখানে ছেড়ে দিলে এখানে যুক্ত করা যাবে",
"upload_button.label": "ছবি বা ভিডিও যুক্ত করতে (এসব ধরণের: JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "যা যুক্ত করতে চাচ্ছেন সেটি বেশি বড়, এখানকার সর্বাধিকের মেমোরির উপরে চলে গেছে।",
"upload_error.poll": "নির্বাচনক্ষেত্রে কোনো ফাইল যুক্ত করা যাবেনা।",
"upload_form.description": "যারা দেখতে পায়না তাদের জন্য এটা বর্ণনা করতে",
"upload_form.focus": "সাধারণ দেখাটি পরিবর্তন করতে",
"upload_form.edit": "Edit",
"upload_form.undo": "মুছে ফেলতে",
"upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply",
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
"upload_modal.detect_text": "Detect text from picture",
"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.preview_label": "Preview ({ratio})",
"upload_progress.label": "যুক্ত করতে পাঠানো হচ্ছে...",
"video.close": "ভিডিওটি বন্ধ করতে",
"video.exit_fullscreen": "পূর্ণ পর্দা থেকে বাইরে বের হতে",

+ 15
- 4
app/javascript/mastodon/locales/ca.json View File

@@ -4,6 +4,7 @@
"account.block": "Bloqueja @{name}",
"account.block_domain": "Amaga-ho tot de {domain}",
"account.blocked": "Bloquejat",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "Missatge directe @{name}",
"account.domain_blocked": "Domini ocult",
"account.edit_profile": "Editar el perfil",
@@ -37,6 +38,7 @@
"account.unmute_notifications": "Activar notificacions de @{name}",
"alert.unexpected.message": "S'ha produït un error inesperat.",
"alert.unexpected.title": "Vaja!",
"autosuggest_hashtag.per_week": "{count} per week",
"boost_modal.combo": "Pots premer {combo} per saltar-te això el proper cop",
"bundle_column_error.body": "S'ha produït un error en carregar aquest component.",
"bundle_column_error.retry": "Torna-ho a provar",
@@ -156,7 +158,7 @@
"home.column_settings.basic": "Bàsic",
"home.column_settings.show_reblogs": "Mostrar impulsos",
"home.column_settings.show_replies": "Mostrar respostes",
"home.column_settings.update_live": "Update in real-time",
"home.column_settings.update_live": "Actualització en temps real",
"intervals.full.days": "{number, plural, one {# dia} other {# dies}}",
"intervals.full.hours": "{number, plural, one {# hora} other {# hores}}",
"intervals.full.minutes": "{number, plural, one {# minut} other {# minuts}}",
@@ -222,7 +224,7 @@
"lists.new.title_placeholder": "Nova llista",
"lists.search": "Cercar entre les persones que segueixes",
"lists.subheading": "Les teves llistes",
"load_pending": "{count, plural, one {# new item} other {# new items}}",
"load_pending": "{count, plural, one {# element nou} other {# elements nous}}",
"loading_indicator.label": "Carregant...",
"media_gallery.toggle_visible": "Alternar visibilitat",
"missing_indicator.label": "No trobat",
@@ -251,6 +253,7 @@
"navigation_bar.profile_directory": "Directori de perfils",
"navigation_bar.public_timeline": "Línia de temps federada",
"navigation_bar.security": "Seguretat",
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
"notification.favourite": "{name} ha afavorit el teu estat",
"notification.follow": "{name} et segueix",
"notification.mention": "{name} t'ha esmentat",
@@ -316,7 +319,7 @@
"search_results.accounts": "Gent",
"search_results.hashtags": "Etiquetes",
"search_results.statuses": "Toots",
"search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
"search_results.statuses_fts_disabled": "La cerca de toots pel seu contingut no està habilitada en aquest servidor Mastodon.",
"search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}",
"status.admin_account": "Obre l'interfície de moderació per a @{name}",
"status.admin_status": "Obre aquest toot a la interfície de moderació",
@@ -370,14 +373,22 @@
"time_remaining.moments": "Moments restants",
"time_remaining.seconds": "{number, plural, one {# segon} other {# segons}} restants",
"trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {gent}} talking",
"trends.refresh": "Refresh",
"ui.beforeunload": "El teu esborrany es perdrà si surts de Mastodon.",
"upload_area.title": "Arrossega i deixa anar per a carregar",
"upload_button.label": "Afegir multimèdia (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "S'ha superat el límit de càrrega d'arxius.",
"upload_error.poll": "No es permet l'enviament de fitxers en les enquestes.",
"upload_form.description": "Descriure els problemes visuals",
"upload_form.focus": "Modificar la previsualització",
"upload_form.edit": "Edit",
"upload_form.undo": "Esborra",
"upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply",
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
"upload_modal.detect_text": "Detect text from picture",
"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.preview_label": "Preview ({ratio})",
"upload_progress.label": "Pujant...",
"video.close": "Tancar el vídeo",
"video.exit_fullscreen": "Sortir de pantalla completa",

+ 21
- 10
app/javascript/mastodon/locales/co.json View File

@@ -1,9 +1,10 @@
{
"account.add_or_remove_from_list": "Aghjustà o toglie da e liste",
"account.add_or_remove_from_list": "Aghjunghje o toglie da e liste",
"account.badges.bot": "Bot",
"account.block": "Bluccà @{name}",
"account.block_domain": "Piattà tuttu da {domain}",
"account.blocked": "Bluccatu",
"account.cancel_follow_request": "Annullà a dumanda d'abbunamentu",
"account.direct": "Missaghju direttu @{name}",
"account.domain_blocked": "Duminiu piattatu",
"account.edit_profile": "Mudificà u prufile",
@@ -37,6 +38,7 @@
"account.unmute_notifications": "Ùn piattà più nutificazione da @{name}",
"alert.unexpected.message": "Un prublemu inaspettatu hè accadutu.",
"alert.unexpected.title": "Uups!",
"autosuggest_hashtag.per_week": "{count} per settimana",
"boost_modal.combo": "Pudete appughjà nant'à {combo} per saltà quessa a prussima volta",
"bundle_column_error.body": "C'hè statu un prublemu caricandu st'elementu.",
"bundle_column_error.retry": "Pruvà torna",
@@ -71,7 +73,7 @@
"compose_form.lock_disclaimer": "U vostru contu ùn hè micca {locked}. Tuttu u mondu pò seguitavi è vede i vostri statuti privati.",
"compose_form.lock_disclaimer.lock": "privatu",
"compose_form.placeholder": "À chè pensate?",
"compose_form.poll.add_option": "Aghjustà una scelta",
"compose_form.poll.add_option": "Aghjunghje scelta",
"compose_form.poll.duration": "Durata di u scandagliu",
"compose_form.poll.option_placeholder": "Scelta {number}",
"compose_form.poll.remove_option": "Toglie sta scelta",
@@ -123,8 +125,8 @@
"empty_column.community": "Ùn c'hè nunda indè a linea lucale. Scrivete puru qualcosa!",
"empty_column.direct": "Ùn avete ancu nisun missaghju direttu. S'è voi mandate o ricevete unu, u vidarete quì.",
"empty_column.domain_blocks": "Ùn c'hè manc'un duminiu bluccatu avà.",
"empty_column.favourited_statuses": "Ùn avete manc'unu statutu favuritu. Quandu aghjusterate unu à i vostri favuriti, sarà mustratu quì.",
"empty_column.favourites": "Nisunu hà aghjustatu stu statutu à i so favuriti. Quandu qualch'unu farà quessa, u so contu sarà mustratu quì.",
"empty_column.favourited_statuses": "Ùn avete manc'unu statutu favuritu. Quandu aghjunghjerate unu à i vostri favuriti, sarà mustratu quì.",
"empty_column.favourites": "Nisunu hà aghjuntu stu statutu à i so favuriti. Quandu qualch'unu farà quessa, u so contu sarà mustratu quì.",
"empty_column.follow_requests": "Ùn avete manc'una dumanda d'abbunamentu. Quandu averete una, sarà mustrata quì.",
"empty_column.hashtag": "Ùn c'hè ancu nunda quì.",
"empty_column.home": "A vostr'accolta hè viota! Pudete andà nant'à {public} o pruvà a ricerca per truvà parsone da siguità.",
@@ -156,7 +158,7 @@
"home.column_settings.basic": "Bàsichi",
"home.column_settings.show_reblogs": "Vede e spartere",
"home.column_settings.show_replies": "Vede e risposte",
"home.column_settings.update_live": "Update in real-time",
"home.column_settings.update_live": "Mette à ghjornu in tempu reale",
"intervals.full.days": "{number, plural, one {# ghjornu} other {# ghjorni}}",
"intervals.full.hours": "{number, plural, one {# ora} other {# ore}}",
"intervals.full.minutes": "{number, plural, one {# minuta} other {# minute}}",
@@ -218,11 +220,11 @@
"lists.delete": "Supprime a lista",
"lists.edit": "Mudificà a lista",
"lists.edit.submit": "Cambià u titulu",
"lists.new.create": "Aghjustà una lista",
"lists.new.create": "Aghjunghje",
"lists.new.title_placeholder": "Titulu di a lista",
"lists.search": "Circà indè i vostr'abbunamenti",
"lists.subheading": "E vo liste",
"load_pending": "{count, plural, one {# new item} other {# new items}}",
"load_pending": "{count, plural, one {# entrata nova} other {# entrate nove}}",
"loading_indicator.label": "Caricamentu...",
"media_gallery.toggle_visible": "Cambià a visibilità",
"missing_indicator.label": "Micca trovu",
@@ -251,6 +253,7 @@
"navigation_bar.profile_directory": "Annuariu di i prufili",
"navigation_bar.public_timeline": "Linea pubblica glubale",
"navigation_bar.security": "Sicurità",
"notification.and_n_others": "è {count, plural, one {# altru} other {# altri}}",
"notification.favourite": "{name} hà aghjuntu u vostru statutu à i so favuriti",
"notification.follow": "{name} v'hà seguitatu",
"notification.mention": "{name} v'hà mintuvatu",
@@ -281,7 +284,7 @@
"poll.refresh": "Attualizà",
"poll.total_votes": "{count, plural, one {# votu} other {# voti}}",
"poll.vote": "Vutà",
"poll_button.add_poll": "Aghjustà un scandagliu",
"poll_button.add_poll": "Aghjunghje",
"poll_button.remove_poll": "Toglie u scandagliu",
"privacy.change": "Mudificà a cunfidenzialità di u statutu",
"privacy.direct.long": "Mandà solu à quelli chì so mintuvati",
@@ -316,7 +319,7 @@
"search_results.accounts": "Ghjente",
"search_results.hashtags": "Hashtag",
"search_results.statuses": "Statuti",
"search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
"search_results.statuses_fts_disabled": "A ricerca di i cuntinuti di i statuti ùn hè micca attivata nant'à stu servore Mastodon.",
"search_results.total": "{count, number} {count, plural, one {risultatu} other {risultati}}",
"status.admin_account": "Apre l'interfaccia di muderazione per @{name}",
"status.admin_status": "Apre stu statutu in l'interfaccia di muderazione",
@@ -370,14 +373,22 @@
"time_remaining.moments": "Ci fermanu qualchi mumentu",
"time_remaining.seconds": "{number, plural, one {# siconda ferma} other {# siconde fermanu}}",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} parlanu",
"trends.refresh": "Attualizà",
"ui.beforeunload": "A bruttacopia sarà persa s'ellu hè chjosu Mastodon.",
"upload_area.title": "Drag & drop per caricà un fugliale",
"upload_button.label": "Aghjunghje un media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Limita di caricamentu di fugliali trapassata.",
"upload_error.poll": "Ùn si pò micca caricà fugliali cù i scandagli.",
"upload_form.description": "Discrive per i malvistosi",
"upload_form.focus": "Cambià a vista",
"upload_form.edit": "Mudificà",
"upload_form.undo": "Sguassà",
"upload_modal.analyzing_picture": "Analisi di u ritrattu…",
"upload_modal.apply": "Affettà",
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
"upload_modal.detect_text": "Ditettà testu da u ritrattu",
"upload_modal.edit_media": "Cambià media",
"upload_modal.hint": "Cliccate o sguillate u chjerchju nant'à a vista per sceglie u puntu fucale chì sarà sempre in vista indè tutte e miniature.",
"upload_modal.preview_label": "Vista ({ratio})",
"upload_progress.label": "Caricamentu...",
"video.close": "Chjudà a video",
"video.exit_fullscreen": "Caccià u pienu screnu",

+ 15
- 4
app/javascript/mastodon/locales/cs.json View File

@@ -4,6 +4,7 @@
"account.block": "Zablokovat uživatele @{name}",
"account.block_domain": "Skrýt vše z {domain}",
"account.blocked": "Blokován/a",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "Poslat přímou zprávu uživateli @{name}",
"account.domain_blocked": "Doména skryta",
"account.edit_profile": "Upravit profil",
@@ -37,6 +38,7 @@
"account.unmute_notifications": "Odkrýt oznámení od uživatele @{name}",
"alert.unexpected.message": "Objevila se neočekávaná chyba.",
"alert.unexpected.title": "Jejda!",
"autosuggest_hashtag.per_week": "{count} per week",
"boost_modal.combo": "Příště můžete pro přeskočení kliknout na {combo}",
"bundle_column_error.body": "Při načítání tohoto komponentu se něco pokazilo.",
"bundle_column_error.retry": "Zkuste to znovu",
@@ -156,7 +158,7 @@
"home.column_settings.basic": "Základní",
"home.column_settings.show_reblogs": "Zobrazit boosty",
"home.column_settings.show_replies": "Zobrazit odpovědi",
"home.column_settings.update_live": "Update in real-time",
"home.column_settings.update_live": "Aktualizovat v reálném čase",
"intervals.full.days": "{number, plural, one {# den} few {# dny} many {# dne} other {# dní}}",
"intervals.full.hours": "{number, plural, one {# hodina} few {# hodiny} many {# hodiny} other {# hodin}}",
"intervals.full.minutes": "{number, plural, one {# minuta} few {# minuty} many {# minuty} other {# minut}}",
@@ -222,7 +224,7 @@
"lists.new.title_placeholder": "Název nového seznamu",
"lists.search": "Hledejte mezi lidmi, které sledujete",
"lists.subheading": "Vaše seznamy",
"load_pending": "{count, plural, one {# new item} other {# new items}}",
"load_pending": "{count, plural, one {# nová položka} few {# nové položky} many {# nových položek} other {# nových položek}}",
"loading_indicator.label": "Načítám…",
"media_gallery.toggle_visible": "Přepínat viditelnost",
"missing_indicator.label": "Nenalezeno",
@@ -251,6 +253,7 @@
"navigation_bar.profile_directory": "Adresář profilů",
"navigation_bar.public_timeline": "Federovaná časová osa",
"navigation_bar.security": "Zabezpečení",
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
"notification.favourite": "{name} si oblíbil/a váš toot",
"notification.follow": "{name} vás začal/a sledovat",
"notification.mention": "{name} vás zmínil/a",
@@ -316,7 +319,7 @@
"search_results.accounts": "Lidé",
"search_results.hashtags": "Hashtagy",
"search_results.statuses": "Tooty",
"search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
"search_results.statuses_fts_disabled": "Vyhledávání tootů podle jejich obsahu není na tomto serveru Mastodon povoleno.",
"search_results.total": "{count, number} {count, plural, one {výsledek} few {výsledky} many {výsledku} other {výsledků}}",
"status.admin_account": "Otevřít moderátorské rozhraní pro uživatele @{name}",
"status.admin_status": "Otevřít tento toot v moderátorském rozhraní",
@@ -370,14 +373,22 @@
"time_remaining.moments": "Zbývá několik sekund",
"time_remaining.seconds": "{number, plural, one {Zbývá # sekunda} few {Zbývají # sekundy} many {Zbývá # sekundy} other {Zbývá # sekund}}",
"trends.count_by_accounts": "{count} {rawCount, plural, one {člověk} few {lidé} many {lidí} other {lidí}} hovoří",
"trends.refresh": "Refresh",
"ui.beforeunload": "Váš koncept se ztratí, pokud Mastodon opustíte.",
"upload_area.title": "Přetažením nahrajete",
"upload_button.label": "Přidat média (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Byl překročen limit nahraných souborů.",
"upload_error.poll": "Nahrávání souborů není povoleno u anket.",
"upload_form.description": "Popis pro zrakově postižené",
"upload_form.focus": "Změnit náhled",
"upload_form.edit": "Edit",
"upload_form.undo": "Smazat",
"upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply",
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
"upload_modal.detect_text": "Detect text from picture",
"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.preview_label": "Preview ({ratio})",
"upload_progress.label": "Nahrávám…",
"video.close": "Zavřít video",
"video.exit_fullscreen": "Ukončit celou obrazovku",

+ 12
- 1
app/javascript/mastodon/locales/cy.json View File

@@ -4,6 +4,7 @@
"account.block": "Blocio @{name}",
"account.block_domain": "Cuddio popeth rhag {domain}",
"account.blocked": "Blociwyd",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "Neges breifat @{name}",
"account.domain_blocked": "Parth wedi ei guddio",
"account.edit_profile": "Golygu proffil",
@@ -37,6 +38,7 @@
"account.unmute_notifications": "Dad-dawelu hysbysiadau o @{name}",
"alert.unexpected.message": "Digwyddodd gwall annisgwyl.",
"alert.unexpected.title": "Wps!",
"autosuggest_hashtag.per_week": "{count} per week",
"boost_modal.combo": "Mae modd gwasgu {combo} er mwyn sgipio hyn tro nesa",
"bundle_column_error.body": "Aeth rhywbeth o'i le tra'n llwytho'r elfen hon.",
"bundle_column_error.retry": "Ceisiwch eto",
@@ -251,6 +253,7 @@
"navigation_bar.profile_directory": "Cyfeiriadur Proffil",
"navigation_bar.public_timeline": "Ffrwd y ffederasiwn",
"navigation_bar.security": "Diogelwch",
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
"notification.favourite": "hoffodd {name} eich tŵt",
"notification.follow": "dilynodd {name} chi",
"notification.mention": "Soniodd {name} amdanoch chi",
@@ -370,14 +373,22 @@
"time_remaining.moments": "Munudau ar ôl",
"time_remaining.seconds": "{number, plural, one {# eiliad} other {# o eiliadau}} ar ôl",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} yn siarad",
"trends.refresh": "Refresh",
"ui.beforeunload": "Mi fyddwch yn colli eich drafft os gadewch Mastodon.",
"upload_area.title": "Llusgwch & gollwing i uwchlwytho",
"upload_button.label": "Ychwanegwch gyfryngau (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Wedi mynd heibio'r uchafswm terfyn uwchlwytho.",
"upload_error.poll": "Nid oes modd uwchlwytho ffeiliau â phleidleisiau.",
"upload_form.description": "Disgrifio i'r rheini a nam ar ei golwg",
"upload_form.focus": "Newid rhagolwg",
"upload_form.edit": "Edit",
"upload_form.undo": "Dileu",
"upload_modal.analyzing_picture": "Analyzing picture…",
"upload_modal.apply": "Apply",
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
"upload_modal.detect_text": "Detect text from picture",
"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.preview_label": "Preview ({ratio})",
"upload_progress.label": "Uwchlwytho...",
"video.close": "Cau fideo",
"video.exit_fullscreen": "Gadael sgrîn llawn",

+ 12
- 1
app/javascript/mastodon/locales/da.json View File

@@ -4,6 +4,7 @@
"account.block": "Bloker @{name}",
"account.block_domain": "Skjul alt fra {domain}",
"account.blocked": "Blokeret",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "Send en direkte besked til @{name}",
"account.domain_blocked": "Domænet er blevet skjult",
"account.edit_profile": "Rediger profil",
@@ -37,6 +38,7 @@
"account.unmute_notifications": "Fjern dæmpningen af notifikationer fra @{name}",
"alert.unexpected.message": "Der opstod en uventet fejl.",
"alert.unexpected.title": "Ups!",
"autosuggest_hashtag.per_week": "{count} per week",
"boost_modal.combo": "Du kan trykke {combo} for at springe dette over næste gang",
"bundle_column_error.body": "Noget gik galt under indlæsningen af dette komponent.",
"bundle_column_error.retry": "Prøv igen",
@@ -251,6 +253,7 @@
"navigation_bar.profile_directory": "Profile directory",
"navigation_bar.public_timeline": "Fælles tidslinje",
"navigation_bar.security": "Sikkerhed",
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
"notification.favourite": "{name} favoriserede din status",
"notification.follow": "{name} fulgte dig",
"notification.mention": "{name} nævnte dig",
@@ -370,14 +373,22 @@
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} snakker",
"trends.refresh": "Refresh",