Browse Source

Merge branch 'master' into live

master
Zac 3 weeks ago
parent
commit
2b5f849fc5
100 changed files with 2053 additions and 541 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. 33
    3
      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. 22
    20
      app/javascript/flavours/glitch/components/status.js
  21. 6
    0
      app/javascript/flavours/glitch/features/account_gallery/components/media_item.js
  22. 1
    1
      app/javascript/flavours/glitch/features/account_gallery/index.js
  23. 25
    0
      app/javascript/flavours/glitch/features/compose/components/character_counter.js
  24. 22
    19
      app/javascript/flavours/glitch/features/compose/components/compose_form.js
  25. 0
    1
      app/javascript/flavours/glitch/features/compose/components/publisher.js
  26. 5
    79
      app/javascript/flavours/glitch/features/compose/components/upload.js
  27. 2
    1
      app/javascript/flavours/glitch/features/compose/components/upload_form.js
  28. 5
    4
      app/javascript/flavours/glitch/features/compose/components/upload_progress.js
  29. 5
    1
      app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
  30. 1
    5
      app/javascript/flavours/glitch/features/compose/containers/upload_container.js
  31. 164
    24
      app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
  32. 6
    5
      app/javascript/flavours/glitch/features/video/index.js
  33. 9
    0
      app/javascript/flavours/glitch/packs/public.js
  34. 14
    0
      app/javascript/flavours/glitch/styles/basics.scss
  35. 25
    9
      app/javascript/flavours/glitch/styles/components/composer.scss
  36. 21
    0
      app/javascript/flavours/glitch/styles/components/index.scss
  37. 5
    0
      app/javascript/flavours/glitch/styles/components/media.scss
  38. 109
    21
      app/javascript/flavours/glitch/styles/components/modal.scss
  39. 5
    0
      app/javascript/flavours/glitch/styles/components/status.scss
  40. 4
    5
      app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
  41. 67
    0
      app/javascript/flavours/glitch/styles/tables.scss
  42. 15
    0
      app/javascript/flavours/glitch/styles/widgets.scss
  43. 4
    0
      app/javascript/flavours/glitch/util/async-components.js
  44. 3
    1
      app/javascript/flavours/glitch/util/numbers.js
  45. 1
    1
      app/javascript/flavours/glitch/util/resize_image.js
  46. 10
    0
      app/javascript/mastodon/actions/app.js
  47. 19
    17
      app/javascript/mastodon/components/status.js
  48. 8
    5
      app/javascript/mastodon/containers/media_container.js
  49. 6
    0
      app/javascript/mastodon/features/account_gallery/components/media_item.js
  50. 1
    1
      app/javascript/mastodon/features/account_gallery/index.js
  51. 6
    79
      app/javascript/mastodon/features/compose/components/upload.js
  52. 2
    1
      app/javascript/mastodon/features/compose/components/upload_form.js
  53. 5
    4
      app/javascript/mastodon/features/compose/components/upload_progress.js
  54. 1
    5
      app/javascript/mastodon/features/compose/containers/upload_container.js
  55. 41
    0
      app/javascript/mastodon/features/ui/components/document_title.js
  56. 162
    22
      app/javascript/mastodon/features/ui/components/focal_point_modal.js
  57. 17
    1
      app/javascript/mastodon/features/ui/index.js
  58. 4
    0
      app/javascript/mastodon/features/ui/util/async-components.js
  59. 6
    5
      app/javascript/mastodon/features/video/index.js
  60. 1
    0
      app/javascript/mastodon/initial_state.js
  61. 15
    4
      app/javascript/mastodon/locales/ar.json
  62. 12
    1
      app/javascript/mastodon/locales/ast.json
  63. 12
    1
      app/javascript/mastodon/locales/bg.json
  64. 12
    1
      app/javascript/mastodon/locales/bn.json
  65. 15
    4
      app/javascript/mastodon/locales/ca.json
  66. 21
    10
      app/javascript/mastodon/locales/co.json
  67. 15
    4
      app/javascript/mastodon/locales/cs.json
  68. 12
    1
      app/javascript/mastodon/locales/cy.json
  69. 12
    1
      app/javascript/mastodon/locales/da.json
  70. 15
    4
      app/javascript/mastodon/locales/de.json
  71. 82
    6
      app/javascript/mastodon/locales/defaultMessages.json
  72. 15
    4
      app/javascript/mastodon/locales/el.json
  73. 12
    1
      app/javascript/mastodon/locales/en.json
  74. 26
    15
      app/javascript/mastodon/locales/eo.json
  75. 25
    14
      app/javascript/mastodon/locales/es.json
  76. 402
    0
      app/javascript/mastodon/locales/et.json
  77. 15
    4
      app/javascript/mastodon/locales/eu.json
  78. 16
    5
      app/javascript/mastodon/locales/fa.json
  79. 12
    1
      app/javascript/mastodon/locales/fi.json
  80. 15
    4
      app/javascript/mastodon/locales/fr.json
  81. 15
    4
      app/javascript/mastodon/locales/gl.json
  82. 12
    1
      app/javascript/mastodon/locales/he.json
  83. 12
    1
      app/javascript/mastodon/locales/hi.json
  84. 12
    1
      app/javascript/mastodon/locales/hr.json
  85. 15
    4
      app/javascript/mastodon/locales/hu.json
  86. 17
    6
      app/javascript/mastodon/locales/hy.json
  87. 12
    1
      app/javascript/mastodon/locales/id.json
  88. 12
    1
      app/javascript/mastodon/locales/io.json
  89. 18
    7
      app/javascript/mastodon/locales/it.json
  90. 15
    4
      app/javascript/mastodon/locales/ja.json
  91. 12
    1
      app/javascript/mastodon/locales/ka.json
  92. 12
    1
      app/javascript/mastodon/locales/kk.json
  93. 15
    4
      app/javascript/mastodon/locales/ko.json
  94. 12
    1
      app/javascript/mastodon/locales/lt.json
  95. 12
    1
      app/javascript/mastodon/locales/lv.json
  96. 12
    1
      app/javascript/mastodon/locales/ms.json
  97. 12
    1
      app/javascript/mastodon/locales/nl.json
  98. 12
    1
      app/javascript/mastodon/locales/no.json
  99. 15
    4
      app/javascript/mastodon/locales/oc.json
  100. 0
    0
      app/javascript/mastodon/locales/pl.json

+ 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

+ 33
- 3
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
@@ -35,7 +65,7 @@ class AboutController < ApplicationController
helper_method :new_user

def set_pack
use_pack 'common'
use_pack 'public'
end

def set_instance_presenter

+ 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

+ 22
- 20
app/javascript/flavours/glitch/components/status.js View File

@@ -486,13 +486,30 @@ class Status extends ImmutablePureComponent {
return null;
}

const handlers = {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleSpoiler: this.handleExpandedToggle,
bookmark: this.handleHotkeyBookmark,
toggleCollapse: this.handleHotkeyCollapse,
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='status focusable' tabIndex='0'>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{' '}
{status.get('content')}
</div>
</HotKeys>
);
}

@@ -628,21 +645,6 @@ class Status extends ImmutablePureComponent {
rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') });
}

const handlers = {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleSpoiler: this.handleExpandedToggle,
bookmark: this.handleHotkeyBookmark,
toggleCollapse: this.handleHotkeyCollapse,
toggleSensitive: this.handleHotkeyToggleSensitive,
};

const computedClass = classNames('status', `status-${status.get('visibility')}`, {
collapsed: isCollapsed,
'has-background': isCollapsed && background,

+ 6
- 0
app/javascript/flavours/glitch/features/account_gallery/components/media_item.js View File

@@ -94,6 +94,12 @@ export default class MediaItem extends ImmutablePureComponent {

if (attachment.get('type') === 'unknown') {
// Skip
} else if (attachment.get('type') === 'audio') {
thumbnail = (
<span className='account-gallery__item__icons'>
<i className='fa fa-music' />
</span>
);
} else if (attachment.get('type') === 'image') {
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;

+ 1
- 1
app/javascript/flavours/glitch/features/account_gallery/index.js View File

@@ -111,7 +111,7 @@ export default class AccountGallery extends ImmutablePureComponent {
}

handleOpenMedia = attachment => {
if (attachment.get('type') === 'video') {
if (['video', 'audio'].includes(attachment.get('type'))) {
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
} else {
const media = attachment.getIn(['status', 'media_attachments']);

+ 25
- 0
app/javascript/flavours/glitch/features/compose/components/character_counter.js View File

@@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import { length } from 'stringz';

export default class CharacterCounter extends React.PureComponent {

static propTypes = {
text: PropTypes.string.isRequired,
max: PropTypes.number.isRequired,
};

checkRemainingText (diff) {
if (diff < 0) {
return <span className='character-counter character-counter--over'>{diff}</span>;
}

return <span className='character-counter'>{diff}</span>;
}

render () {
const diff = this.props.max - length(this.props.text);
return this.checkRemainingText(diff);
}

}

+ 22
- 19
app/javascript/flavours/glitch/features/compose/components/compose_form.js View File

@@ -15,6 +15,8 @@ import { countableText } from 'flavours/glitch/util/counter';
import OptionsContainer from '../containers/options_container';
import Publisher from './publisher';
import TextareaIcons from './textarea_icons';
import { maxChars } from 'flavours/glitch/util/initial_state';
import CharacterCounter from './character_counter';

const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -119,14 +121,8 @@ class ComposeForm extends ImmutablePureComponent {

// Submit unless there are media with missing descriptions
if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
const firstWithoutDescription = media.findIndex(item => !item.get('description'));
if (uploadForm) {
const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input');
if (inputs.length == media.size && firstWithoutDescription !== -1) {
inputs[firstWithoutDescription].focus();
}
}
onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null);
const firstWithoutDescription = media.find(item => !item.get('description'));
onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id'));
} else if (onSubmit) {
onSubmit(this.context.router ? this.context.router.history : null);
}
@@ -298,6 +294,8 @@ class ComposeForm extends ImmutablePureComponent {

let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);

const countText = `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`;

return (
<div className='composer'>
<WarningContainer />
@@ -347,19 +345,24 @@ class ComposeForm extends ImmutablePureComponent {
</div>
</AutosuggestTextarea>

<OptionsContainer
advancedOptions={advancedOptions}
disabled={isSubmitting}
onChangeVisibility={onChangeVisibility}
onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
onUpload={onPaste}
privacy={privacy}
sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
/>
<div className='composer--options-wrapper'>
<OptionsContainer
advancedOptions={advancedOptions}
disabled={isSubmitting}
onChangeVisibility={onChangeVisibility}
onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
onUpload={onPaste}
privacy={privacy}
sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
/>
<div className='compose--counter-wrapper'>
<CharacterCounter text={countText} max={maxChars} />
</div>
</div>

<Publisher
countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
countText={countText}
disabled={disabledButton}
onSecondarySubmit={handleSecondarySubmit}
onSubmit={handleSubmit}

+ 0
- 1
app/javascript/flavours/glitch/features/compose/components/publisher.js View File

@@ -49,7 +49,6 @@ class Publisher extends ImmutablePureComponent {

return (
<div className={computedClass}>
<span className='count'>{diff}</span>
{sideArm && sideArm !== 'none' ? (
<Button
className='side_arm'

+ 5
- 79
app/javascript/flavours/glitch/features/compose/components/upload.js View File

@@ -4,18 +4,12 @@ import PropTypes from 'prop-types';
import Motion from 'flavours/glitch/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 'flavours/glitch/components/icon';
import { isUserTouching } from 'flavours/glitch/util/is_mobile';

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

// The component.
export default @injectIntl
class Upload extends ImmutablePureComponent {
export default class Upload extends ImmutablePureComponent {

static contextTypes = {
router: PropTypes.object,
@@ -23,30 +17,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'));
@@ -57,69 +31,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 || isUserTouching();
const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
const computedClass = classNames('composer--upload_form--item', { active });
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={computedClass} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
<div className='composer--upload_form--item' tabIndex='0' role='button'>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}>
{({ scale }) => (
<div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className={classNames('composer--upload_form--actions', { active })}>
<div className={classNames('composer--upload_form--actions', { active: true })}>
<button className='icon-button' onClick={this.handleUndoClick}><Icon icon='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('composer--upload_form--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/flavours/glitch/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 {
static propTypes = {
@@ -15,7 +16,7 @@ export default class UploadForm extends ImmutablePureComponent {

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

{mediaIds.size > 0 && (
<div className='content'>

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

@@ -2,7 +2,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { FormattedMessage } from 'react-intl';
import Icon from 'flavours/glitch/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;
@@ -21,10 +22,10 @@ export default class UploadProgress extends React.PureComponent {

return (
<div className='composer--upload_form--progress'>
<Icon icon='upload' />
<Icon icon={icon} />

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

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

+ 5
- 1
app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js View File

@@ -25,6 +25,8 @@ const messages = defineMessages({
defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm',
defaultMessage: 'Send anyway' },
missingDescriptionEdit: { id: 'confirmations.missing_media_description.edit',
defaultMessage: 'Edit media' },
});

// State mapping.
@@ -112,11 +114,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(changeComposeVisibility(value));
},

onMediaDescriptionConfirm(routerHistory) {
onMediaDescriptionConfirm(routerHistory, mediaId) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.missingDescriptionMessage),
confirm: intl.formatMessage(messages.missingDescriptionConfirm),
onConfirm: () => dispatch(submitCompose(routerHistory)),
secondary: intl.formatMessage(messages.missingDescriptionEdit),
onSecondary: () => dispatch(openModal('FOCAL_POINT', { id: mediaId })),
onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
}));
},

+ 1
- 5
app/javascript/flavours/glitch/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 'flavours/glitch/actions/compose';
import { undoUploadCompose } from 'flavours/glitch/actions/compose';
import { openModal } from 'flavours/glitch/actions/modal';
import { submitCompose } from 'flavours/glitch/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 }));
},

+ 164
- 24
app/javascript/flavours/glitch/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 'flavours/glitch/actions/compose';
import { getPointerPosition } from 'flavours/glitch/features/video';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'flavours/glitch/components/icon_button';
import Button from 'flavours/glitch/components/button';
import Video from 'flavours/glitch/features/video';
import Textarea from 'react-textarea-autosize';
import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter';
import { length } from 'stringz';
import { Tesseract as fetchTesseract } from 'flavours/glitch/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)}` }));
},

});

@connect(mapStateToProps, mapDispatchToProps)
export default class FocalPointModal extends ImmutablePureComponent {
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 @@ export default class FocalPointModal extends ImmutablePureComponent {
focusX: 0,
focusY: 0,
dragging: false,
description: '',
dirty: false,
progress: 0,
};

componentWillMount () {
@@ -57,6 +84,14 @@ export default 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 @@ export default 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 @@ export default 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>
);

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

@@ -101,6 +101,7 @@ export default class Video extends React.PureComponent {
fullwidth: PropTypes.bool,
detailed: PropTypes.bool,
inline: PropTypes.bool,
editable: PropTypes.bool,
cacheWidth: PropTypes.func,
intl: PropTypes.object.isRequired,
visible: PropTypes.bool,
@@ -393,7 +394,7 @@ export default class Video extends React.PureComponent {
}

render () {
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link } = this.props;
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, 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;
const playerStyle = {};
@@ -401,7 +402,7 @@ export default class Video extends React.PureComponent {
const volumeWidth = (muted) ? 0 : volume * this.volWidth;
const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);

const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth });
const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth });

let { width, height } = this.props;

@@ -443,7 +444,7 @@ export default 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}
@@ -465,7 +466,7 @@ export default 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>
@@ -508,7 +509,7 @@ export default 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}><i className='fa fa-fw fa-eye-slash' /></button>}
{(!onCloseVideo && !editable) && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>}
{(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>}
{onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>}
<button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button>

+ 9
- 0
app/javascript/flavours/glitch/packs/public.js View File

@@ -104,6 +104,15 @@ function main() {

delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));

delegate(document, '.blocks-table button.icon-button', 'click', function(e) {
e.preventDefault();

const classList = this.firstElementChild.classList;
classList.toggle('fa-chevron-down');
classList.toggle('fa-chevron-up');
this.parentElement.parentElement.nextElementSibling.classList.toggle('hidden');
});
});
}


+ 14
- 0
app/javascript/flavours/glitch/styles/basics.scss View File

@@ -133,3 +133,17 @@ button {
outline: 0 !important;
}
}

.layout-single-column .app-holder {
&,
& > div {
min-height: 100vh;
}
}

.layout-multiple-columns .app-holder {
&,
& > div {
height: 100%;
}
}

+ 25
- 9
app/javascript/flavours/glitch/styles/components/composer.scss View File

@@ -2,6 +2,18 @@
padding: 10px;
}

.character-counter {
cursor: default;
font-family: $font-sans-serif, sans-serif;
font-size: 14px;
font-weight: 600;
color: $lighter-text-color;

&.character-counter--over {
color: $warning-red;
}
}

.no-reduce-motion .composer--spoiler {
transition: height 0.4s ease, opacity 0.4s ease;
}
@@ -489,12 +501,18 @@
background: $simple-background-color;
}

.composer--options {
.composer--options-wrapper {
padding: 10px;
background: darken($simple-background-color, 8%);
box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05);
border-radius: 0 0 4px 4px;
height: 27px;
display: flex;
justify-content: space-between;
flex: 0 0 auto;
}

.composer--options {
display: flex;
flex: 0 0 auto;

& > * {
@@ -519,6 +537,11 @@
}
}

.compose--counter-wrapper {
align-self: center;
margin-right: 4px;
}

.composer--options--dropdown {
&.open {
& > .value {
@@ -589,13 +612,6 @@
justify-content: flex-end;
flex: 0 0 auto;

& > .count {
display: inline-block;
margin: 0 16px 0 8px;
font-size: 16px;
line-height: 36px;
}

& > .primary {
display: inline-block;
margin: 0;

+ 21
- 0
app/javascript/flavours/glitch/styles/components/index.scss View File

@@ -3,6 +3,27 @@
-ms-overflow-style: -ms-autohiding-scrollbar;
}

.link-button {
display: block;
font-size: 15px;
line-height: 20px;
color: $ui-highlight-color;
border: 0;
background: transparent;
padding: 0;
cursor: pointer;

&:hover,
&:active {
text-decoration: underline;
}

&:disabled {
color: $ui-primary-color;
cursor: default;
}
}

.button {
background-color: darken($ui-highlight-color, 3%);
border: 10px none;

+ 5
- 0
app/javascript/flavours/glitch/styles/components/media.scss View File

@@ -338,6 +338,11 @@
position: relative;
background: $base-shadow-color;
max-width: 100%;
border-radius: 4px;

&.editable {
border-radius: 0;
}

&:focus {
outline: 0;

+ 109
- 21
app/javascript/flavours/glitch/styles/components/modal.scss View File

@@ -528,7 +528,8 @@
}
}

.report-modal__statuses {
.report-modal__statuses,
.focal-point-modal__content {
flex: 1 1 auto;
min-height: 20vh;
max-height: 80vh;
@@ -544,6 +545,12 @@
}
}

.focal-point-modal__content {
@media screen and (max-width: 480px) {
max-height: 40vh;
}
}

.report-modal__comment {
padding: 20px;
border-right: 1px solid $ui-secondary-color;
@@ -565,16 +572,56 @@
padding: 10px;
font-family: inherit;
font-size: 14px;
resize: vertical;
resize: none;
border: 0;
outline: 0;
border-radius: 4px;
border: 1px solid $ui-secondary-color;
margin-bottom: 20px;
min-height: 100px;
max-height: 50vh;
margin-bottom: 10px;

&:focus {
border: 1px solid darken($ui-secondary-color, 8%);
}

&__wrapper {
background: $white;
border: 1px solid $ui-secondary-color;
margin-bottom: 10px;
border-radius: 4px;

.setting-text {
border: 0;
margin-bottom: 0;
border-radius: 0;

&:focus {
border: 0;
}
}

&__modifiers {
color: $inverted-text-color;
font-family: inherit;
font-size: 14px;
background: $white;
}
}

&__toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
}

.setting-text-label {
display: block;
color: $inverted-text-color;
font-size: 14px;
font-weight: 500;
margin-bottom: 10px;
}

.setting-toggle {
@@ -598,15 +645,6 @@
}
}

.report-modal__target {
padding: 20px;

.media-modal__close {
top: 19px;
right: 15px;
}
}

.actions-modal {
.status {
overflow-y: auto;
@@ -725,6 +763,15 @@
}
}

.report-modal__target {
padding: 15px;

.media-modal__close {
top: 14px;
right: 15px;
}
}

.embed-modal {
max-width: 80vw;
max-height: 80vh;
@@ -787,19 +834,23 @@

.focal-point {
position: relative;
cursor: pointer;
cursor: move;
overflow: hidden;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: $base-shadow-color;

&.dragging {
cursor: move;
}

img {
max-width: 80vw;
img,
video {
display: block;
max-height: 80vh;
width: auto;
width: 100%;
height: auto;
margin: auto;
margin: 0;
object-fit: contain;
background: $base-shadow-color;
}

&__reticle {
@@ -819,6 +870,43 @@
top: 0;
left: 0;
}

&__preview {
position: absolute;
bottom: 10px;
right: 10px;
z-index: 2;
cursor: move;
transition: opacity 0.1s ease;

&:hover {
opacity: 0.5;
}

strong {
color: $primary-text-color;
font-size: 14px;
font-weight: 500;
display: block;
margin-bottom: 5px;
}

div {
border-radius: 4px;
box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);
}
}

@media screen and (max-width: 480px) {
img,
video {
max-height: 100%;
}

&__preview {
display: none;
}
}
}

.filtered-status-info {

+ 5
- 0
app/javascript/flavours/glitch/styles/components/status.scss View File

@@ -81,6 +81,11 @@
text-align: sub;
}

sup {
font-size: smaller;
vertical-align: super;
}

ul, ol {
margin-left: 1em;


+ 4
- 5
app/javascript/flavours/glitch/styles/mastodon-light/diff.scss View File

@@ -135,13 +135,12 @@
}
}

.composer--options {
.composer--options-wrapper {
background: lighten($ui-base-color, 10%);
box-shadow: unset;
}

& > hr {
display: none;
}
.composer--options > hr {
display: none;
}

.composer--options--dropdown--content--item {

+ 67
- 0
app/javascript/flavours/glitch/styles/tables.scss View File

@@ -237,3 +237,70 @@ a.table-action-link {
}
}
}

.blocks-table {
width: 100%;
max-width: 100%;
border-spacing: 0;
border-collapse: collapse;
table-layout: fixed;
border: 1px solid darken($ui-base-color, 8%);

thead {
border: 1px solid darken($ui-base-color, 8%);
background: darken($ui-base-color, 4%);
font-weight: 500;

th.severity-column {
width: 120px;
}

th.button-column {
width: 23px;
}
}

tbody > tr {
border: 1px solid darken($ui-base-color, 8%);
border-bottom: 0;
background: darken($ui-base-color, 4%);

&:hover {
background: darken($ui-base-color, 2%);
}

&.even {
background: $ui-base-color;

&:hover {
background: lighten($ui-base-color, 2%);
}
}

&.rationale {
background: lighten($ui-base-color, 4%);
border-top: 0;

&:hover {
background: lighten($ui-base-color, 6%);
}

&.hidden {
display: none;
}
}

td:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
}

th,
td {
padding: 8px;
line-height: 18px;
vertical-align: top;
text-align: left;
}
}

+ 15
- 0
app/javascript/flavours/glitch/styles/widgets.scss View File

@@ -109,6 +109,15 @@
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
}

.placeholder-widget {
padding: 16px;
border-radius: 4px;
border: 2px dashed $dark-text-color;
text-align: center;
color: $darker-text-color;
margin-bottom: 10px;
}

.contact-widget,
.landing-page__information.contact-widget {
box-sizing: border-box;
@@ -521,6 +530,12 @@ $fluid-breakpoint: $maximum-width + 20px;
a {
font-size: 14px;
line-height: 20px;
}
}

.notice-widget,
.placeholder-widget {
a {
text-decoration: none;
font-weight: 500;
color: $ui-highlight-color;

+ 4
- 0
app/javascript/flavours/glitch/util/async-components.js View File

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

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

+ 3
- 1
app/javascript/flavours/glitch/util/numbers.js View File

@@ -4,7 +4,9 @@ import { FormattedNumber } from 'react-intl';
export const shortNumberFormat = number => {
if (number < 1000) {
return <FormattedNumber value={number} />;
} else {
} else if (number < 1000000) {
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>;
} else {
return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={1} />M</Fragment>;
}
};

+ 1
- 1
app/javascript/flavours/glitch/util/resize_image.js View File

@@ -71,7 +71,7 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) =
// and return an all-white image instead. Assume reading failed if the resized
// image is perfectly white.
const imageData = context.getImageData(0, 0, width, height);
if (imageData.every(value => value === 255)) {
if (imageData.data.every(value => value === 255)) {
throw 'Failed to read from canvas';
}


+ 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
- 0
app/javascript/mastodon/features/account_gallery/components/media_item.js View File

@@ -96,6 +96,12 @@ export default class MediaItem extends ImmutablePureComponent {

if (attachment.get('type') === 'unknown') {
// Skip
} else if (attachment.get('type') === 'audio') {
thumbnail = (
<span className='account-gallery__item__icons'>
<Icon id='music' />
</span>
);
} else if (attachment.get('type') === 'image') {
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;

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

@@ -100,7 +100,7 @@ class AccountGallery extends ImmutablePureComponent {
}