commit
28758a3965
39
CHANGELOG.md
39
CHANGELOG.md
|
@ -3,6 +3,45 @@ Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [2.8.1] - 2019-05-04
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add link to existing domain block when trying to block an already-blocked domain ([ThibG](https://github.com/tootsuite/mastodon/pull/10663))
|
||||||
|
- Add button to view context to media modal when opened from account gallery in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10676))
|
||||||
|
- Add ability to create multiple-choice polls in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10603))
|
||||||
|
- Add `GITHUB_REPOSITORY` and `SOURCE_BASE_URL` environment variables ([rosylilly](https://github.com/tootsuite/mastodon/pull/10600))
|
||||||
|
- Add `/interact/` paths to `robots.txt` ([ThibG](https://github.com/tootsuite/mastodon/pull/10666))
|
||||||
|
- Add `blurhash` to the Attachment entity in the REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change hidden media to be shown as a blurhash-based colorful gradient instead of a black box in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
|
||||||
|
- Change rejected media to be shown as a blurhash-based gradient instead of a list of filenames in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
|
||||||
|
- Change e-mail whitelist/blacklist to not be checked when invited ([Gargron](https://github.com/tootsuite/mastodon/pull/10683))
|
||||||
|
- Change cache header of REST API results to no-cache ([ThibG](https://github.com/tootsuite/mastodon/pull/10655))
|
||||||
|
- Change the "mark media as sensitive" button to be more obvious in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10673), [Gargron](https://github.com/tootsuite/mastodon/pull/10682))
|
||||||
|
- Change account gallery in web UI to display 3 columns, open media modal ([Gargron](https://github.com/tootsuite/mastodon/pull/10667), [Gargron](https://github.com/tootsuite/mastodon/pull/10674))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix LDAP/PAM/SAML/CAS users not being pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10621))
|
||||||
|
- Fix accounts created through tootctl not being always pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10684))
|
||||||
|
- Fix Sidekiq retrying ActivityPub processing jobs that fail validation ([ThibG](https://github.com/tootsuite/mastodon/pull/10614))
|
||||||
|
- Fix toots not being scrolled into view sometimes through keyboard selection ([ThibG](https://github.com/tootsuite/mastodon/pull/10593))
|
||||||
|
- Fix expired invite links being usable to bypass approval mode ([ThibG](https://github.com/tootsuite/mastodon/pull/10657))
|
||||||
|
- Fix not being able to save e-mail preference for new pending accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/10622))
|
||||||
|
- Fix upload progressbar when image resizing is involved ([ThibG](https://github.com/tootsuite/mastodon/pull/10632))
|
||||||
|
- Fix block action not automatically cancelling pending follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/10633))
|
||||||
|
- Fix stoplight logging to stderr separate from Rails logger ([Gargron](https://github.com/tootsuite/mastodon/pull/10624))
|
||||||
|
- Fix sign up button not saying sign up when invite is used ([Gargron](https://github.com/tootsuite/mastodon/pull/10623))
|
||||||
|
- Fix health checks in Docker Compose configuration ([fabianonline](https://github.com/tootsuite/mastodon/pull/10553))
|
||||||
|
- Fix modal items not being scrollable on touch devices ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/10605))
|
||||||
|
- Fix Keybase configuration using wrong domain when a web domain is used ([BenLubar](https://github.com/tootsuite/mastodon/pull/10565))
|
||||||
|
- Fix avatar GIFs not being animated on-hover on public profiles ([hyenagirl64](https://github.com/tootsuite/mastodon/pull/10549))
|
||||||
|
- Fix OpenGraph parser not understanding some valid property meta tags ([da2x](https://github.com/tootsuite/mastodon/pull/10604))
|
||||||
|
- Fix wrong fonts being displayed when Roboto is installed on user's machine ([ThibG](https://github.com/tootsuite/mastodon/pull/10594))
|
||||||
|
- Fix confirmation modals being too narrow for a secondary action button ([ThibG](https://github.com/tootsuite/mastodon/pull/10586))
|
||||||
|
|
||||||
## [2.8.0] - 2019-04-10
|
## [2.8.0] - 2019-04-10
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|
9
Gemfile
9
Gemfile
|
@ -21,6 +21,7 @@ gem 'fog-openstack', '~> 0.3', require: false
|
||||||
gem 'paperclip', '~> 6.0'
|
gem 'paperclip', '~> 6.0'
|
||||||
gem 'paperclip-av-transcoder', '~> 0.6'
|
gem 'paperclip-av-transcoder', '~> 0.6'
|
||||||
gem 'streamio-ffmpeg', '~> 3.0'
|
gem 'streamio-ffmpeg', '~> 3.0'
|
||||||
|
gem 'blurhash', '~> 0.1'
|
||||||
|
|
||||||
gem 'active_model_serializers', '~> 0.10'
|
gem 'active_model_serializers', '~> 0.10'
|
||||||
gem 'addressable', '~> 2.6'
|
gem 'addressable', '~> 2.6'
|
||||||
|
@ -66,7 +67,7 @@ gem 'ox', '~> 2.10'
|
||||||
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
|
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
|
||||||
gem 'pundit', '~> 2.0'
|
gem 'pundit', '~> 2.0'
|
||||||
gem 'premailer-rails'
|
gem 'premailer-rails'
|
||||||
gem 'rack-attack', '~> 5.4'
|
gem 'rack-attack', '~> 6.0'
|
||||||
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
|
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
|
||||||
gem 'rails-i18n', '~> 5.1'
|
gem 'rails-i18n', '~> 5.1'
|
||||||
gem 'rails-settings-cached', '~> 0.6'
|
gem 'rails-settings-cached', '~> 0.6'
|
||||||
|
@ -124,14 +125,14 @@ group :development do
|
||||||
gem 'annotate', '~> 2.7'
|
gem 'annotate', '~> 2.7'
|
||||||
gem 'better_errors', '~> 2.5'
|
gem 'better_errors', '~> 2.5'
|
||||||
gem 'binding_of_caller', '~> 0.7'
|
gem 'binding_of_caller', '~> 0.7'
|
||||||
gem 'bullet', '~> 5.9'
|
gem 'bullet', '~> 6.0'
|
||||||
gem 'letter_opener', '~> 1.7'
|
gem 'letter_opener', '~> 1.7'
|
||||||
gem 'letter_opener_web', '~> 1.3'
|
gem 'letter_opener_web', '~> 1.3'
|
||||||
gem 'memory_profiler'
|
gem 'memory_profiler'
|
||||||
gem 'rubocop', '~> 0.67', require: false
|
gem 'rubocop', '~> 0.68', require: false
|
||||||
gem 'brakeman', '~> 4.5', require: false
|
gem 'brakeman', '~> 4.5', require: false
|
||||||
gem 'bundler-audit', '~> 0.6', require: false
|
gem 'bundler-audit', '~> 0.6', require: false
|
||||||
gem 'scss_lint', '~> 0.57', require: false
|
gem 'scss_lint', '~> 0.58', require: false
|
||||||
|
|
||||||
gem 'capistrano', '~> 3.11'
|
gem 'capistrano', '~> 3.11'
|
||||||
gem 'capistrano-rails', '~> 1.4'
|
gem 'capistrano-rails', '~> 1.4'
|
||||||
|
|
39
Gemfile.lock
39
Gemfile.lock
|
@ -66,8 +66,8 @@ GEM
|
||||||
public_suffix (>= 2.0.2, < 4.0)
|
public_suffix (>= 2.0.2, < 4.0)
|
||||||
airbrussh (1.3.0)
|
airbrussh (1.3.0)
|
||||||
sshkit (>= 1.6.1, != 1.7.0)
|
sshkit (>= 1.6.1, != 1.7.0)
|
||||||
annotate (2.7.4)
|
annotate (2.7.5)
|
||||||
activerecord (>= 3.2, < 6.0)
|
activerecord (>= 3.2, < 7.0)
|
||||||
rake (>= 10.4, < 13.0)
|
rake (>= 10.4, < 13.0)
|
||||||
arel (9.0.0)
|
arel (9.0.0)
|
||||||
ast (2.4.0)
|
ast (2.4.0)
|
||||||
|
@ -99,12 +99,14 @@ GEM
|
||||||
rack (>= 0.9.0)
|
rack (>= 0.9.0)
|
||||||
binding_of_caller (0.8.0)
|
binding_of_caller (0.8.0)
|
||||||
debug_inspector (>= 0.0.1)
|
debug_inspector (>= 0.0.1)
|
||||||
bootsnap (1.4.3)
|
blurhash (0.1.3)
|
||||||
|
ffi (~> 1.10.0)
|
||||||
|
bootsnap (1.4.4)
|
||||||
msgpack (~> 1.0)
|
msgpack (~> 1.0)
|
||||||
brakeman (4.5.0)
|
brakeman (4.5.0)
|
||||||
browser (2.5.3)
|
browser (2.5.3)
|
||||||
builder (3.2.3)
|
builder (3.2.3)
|
||||||
bullet (5.9.0)
|
bullet (6.0.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
uniform_notifier (~> 1.11)
|
uniform_notifier (~> 1.11)
|
||||||
bundler-audit (0.6.1)
|
bundler-audit (0.6.1)
|
||||||
|
@ -205,7 +207,7 @@ GEM
|
||||||
et-orbi (1.1.6)
|
et-orbi (1.1.6)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (0.62.0)
|
excon (0.62.0)
|
||||||
fabrication (2.20.1)
|
fabrication (2.20.2)
|
||||||
faker (1.9.3)
|
faker (1.9.3)
|
||||||
i18n (>= 0.7)
|
i18n (>= 0.7)
|
||||||
faraday (0.15.0)
|
faraday (0.15.0)
|
||||||
|
@ -348,7 +350,7 @@ GEM
|
||||||
mini_mime (1.0.1)
|
mini_mime (1.0.1)
|
||||||
mini_portile2 (2.4.0)
|
mini_portile2 (2.4.0)
|
||||||
minitest (5.11.3)
|
minitest (5.11.3)
|
||||||
msgpack (1.2.9)
|
msgpack (1.2.10)
|
||||||
multi_json (1.13.1)
|
multi_json (1.13.1)
|
||||||
multipart-post (2.0.0)
|
multipart-post (2.0.0)
|
||||||
necromancer (0.4.0)
|
necromancer (0.4.0)
|
||||||
|
@ -395,7 +397,7 @@ GEM
|
||||||
parallel (1.17.0)
|
parallel (1.17.0)
|
||||||
parallel_tests (2.28.0)
|
parallel_tests (2.28.0)
|
||||||
parallel
|
parallel
|
||||||
parser (2.6.2.1)
|
parser (2.6.3.0)
|
||||||
ast (~> 2.4.0)
|
ast (~> 2.4.0)
|
||||||
pastel (0.7.2)
|
pastel (0.7.2)
|
||||||
equatable (~> 0.5.0)
|
equatable (~> 0.5.0)
|
||||||
|
@ -420,14 +422,13 @@ GEM
|
||||||
pry (~> 0.10)
|
pry (~> 0.10)
|
||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
psych (3.1.0)
|
|
||||||
public_suffix (3.0.3)
|
public_suffix (3.0.3)
|
||||||
puma (3.12.1)
|
puma (3.12.1)
|
||||||
pundit (2.0.1)
|
pundit (2.0.1)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.1.6)
|
raabro (1.1.6)
|
||||||
rack (2.0.7)
|
rack (2.0.7)
|
||||||
rack-attack (5.4.2)
|
rack-attack (6.0.0)
|
||||||
rack (>= 1.0, < 3)
|
rack (>= 1.0, < 3)
|
||||||
rack-cors (1.0.3)
|
rack-cors (1.0.3)
|
||||||
rack-protection (2.0.5)
|
rack-protection (2.0.5)
|
||||||
|
@ -472,8 +473,8 @@ GEM
|
||||||
rainbow (3.0.0)
|
rainbow (3.0.0)
|
||||||
rake (12.3.2)
|
rake (12.3.2)
|
||||||
rb-fsevent (0.10.3)
|
rb-fsevent (0.10.3)
|
||||||
rb-inotify (0.9.10)
|
rb-inotify (0.10.0)
|
||||||
ffi (>= 0.5.0, < 2)
|
ffi (~> 1.0)
|
||||||
rdf (3.0.9)
|
rdf (3.0.9)
|
||||||
hamster (~> 3.0)
|
hamster (~> 3.0)
|
||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
|
@ -528,11 +529,10 @@ GEM
|
||||||
rspec-core (~> 3.0, >= 3.0.0)
|
rspec-core (~> 3.0, >= 3.0.0)
|
||||||
sidekiq (>= 2.4.0)
|
sidekiq (>= 2.4.0)
|
||||||
rspec-support (3.8.0)
|
rspec-support (3.8.0)
|
||||||
rubocop (0.67.2)
|
rubocop (0.68.1)
|
||||||
jaro_winkler (~> 1.5.1)
|
jaro_winkler (~> 1.5.1)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 2.5, != 2.5.1.1)
|
parser (>= 2.5, != 2.5.1.1)
|
||||||
psych (>= 3.1.0)
|
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 1.4.0, < 1.6)
|
unicode-display_width (>= 1.4.0, < 1.6)
|
||||||
|
@ -546,12 +546,12 @@ GEM
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.8.0)
|
nokogiri (>= 1.8.0)
|
||||||
nokogumbo (~> 2.0)
|
nokogumbo (~> 2.0)
|
||||||
sass (3.6.0)
|
sass (3.7.4)
|
||||||
sass-listen (~> 4.0.0)
|
sass-listen (~> 4.0.0)
|
||||||
sass-listen (4.0.0)
|
sass-listen (4.0.0)
|
||||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||||
rb-inotify (~> 0.9, >= 0.9.7)
|
rb-inotify (~> 0.9, >= 0.9.7)
|
||||||
scss_lint (0.57.1)
|
scss_lint (0.58.0)
|
||||||
rake (>= 0.9, < 13)
|
rake (>= 0.9, < 13)
|
||||||
sass (~> 3.5, >= 3.5.5)
|
sass (~> 3.5, >= 3.5.5)
|
||||||
sidekiq (5.2.7)
|
sidekiq (5.2.7)
|
||||||
|
@ -663,10 +663,11 @@ DEPENDENCIES
|
||||||
aws-sdk-s3 (~> 1.36)
|
aws-sdk-s3 (~> 1.36)
|
||||||
better_errors (~> 2.5)
|
better_errors (~> 2.5)
|
||||||
binding_of_caller (~> 0.7)
|
binding_of_caller (~> 0.7)
|
||||||
|
blurhash (~> 0.1)
|
||||||
bootsnap (~> 1.4)
|
bootsnap (~> 1.4)
|
||||||
brakeman (~> 4.5)
|
brakeman (~> 4.5)
|
||||||
browser
|
browser
|
||||||
bullet (~> 5.9)
|
bullet (~> 6.0)
|
||||||
bundler-audit (~> 0.6)
|
bundler-audit (~> 0.6)
|
||||||
capistrano (~> 3.11)
|
capistrano (~> 3.11)
|
||||||
capistrano-rails (~> 1.4)
|
capistrano-rails (~> 1.4)
|
||||||
|
@ -737,7 +738,7 @@ DEPENDENCIES
|
||||||
pry-rails (~> 0.3)
|
pry-rails (~> 0.3)
|
||||||
puma (~> 3.12)
|
puma (~> 3.12)
|
||||||
pundit (~> 2.0)
|
pundit (~> 2.0)
|
||||||
rack-attack (~> 5.4)
|
rack-attack (~> 6.0)
|
||||||
rack-cors (~> 1.0)
|
rack-cors (~> 1.0)
|
||||||
rails (~> 5.2.3)
|
rails (~> 5.2.3)
|
||||||
rails-controller-testing (~> 1.0)
|
rails-controller-testing (~> 1.0)
|
||||||
|
@ -750,9 +751,9 @@ DEPENDENCIES
|
||||||
rqrcode (~> 0.10)
|
rqrcode (~> 0.10)
|
||||||
rspec-rails (~> 3.8)
|
rspec-rails (~> 3.8)
|
||||||
rspec-sidekiq (~> 3.0)
|
rspec-sidekiq (~> 3.0)
|
||||||
rubocop (~> 0.67)
|
rubocop (~> 0.68)
|
||||||
sanitize (~> 5.0)
|
sanitize (~> 5.0)
|
||||||
scss_lint (~> 0.57)
|
scss_lint (~> 0.58)
|
||||||
sidekiq (~> 5.2)
|
sidekiq (~> 5.2)
|
||||||
sidekiq-bulk (~> 0.2.0)
|
sidekiq-bulk (~> 0.2.0)
|
||||||
sidekiq-scheduler (~> 3.0)
|
sidekiq-scheduler (~> 3.0)
|
||||||
|
|
|
@ -13,13 +13,25 @@ module Admin
|
||||||
authorize :domain_block, :create?
|
authorize :domain_block, :create?
|
||||||
|
|
||||||
@domain_block = DomainBlock.new(resource_params)
|
@domain_block = DomainBlock.new(resource_params)
|
||||||
|
existing_domain_block = resource_params[:domain].present? ? DomainBlock.find_by(domain: resource_params[:domain]) : nil
|
||||||
|
|
||||||
if @domain_block.save
|
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
|
||||||
DomainBlockWorker.perform_async(@domain_block.id)
|
@domain_block.save
|
||||||
log_action :create, @domain_block
|
flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
|
||||||
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
|
@domain_block.errors[:domain].clear
|
||||||
else
|
|
||||||
render :new
|
render :new
|
||||||
|
else
|
||||||
|
if existing_domain_block.present?
|
||||||
|
@domain_block = existing_domain_block
|
||||||
|
@domain_block.update(resource_params)
|
||||||
|
end
|
||||||
|
if @domain_block.save
|
||||||
|
DomainBlockWorker.perform_async(@domain_block.id)
|
||||||
|
log_action :create, @domain_block
|
||||||
|
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
|
||||||
|
else
|
||||||
|
render :new
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController
|
||||||
skip_before_action :store_current_location
|
skip_before_action :store_current_location
|
||||||
skip_before_action :check_user_permissions
|
skip_before_action :check_user_permissions
|
||||||
|
|
||||||
|
before_action :set_cache_headers
|
||||||
|
|
||||||
protect_from_forgery with: :null_session
|
protect_from_forgery with: :null_session
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
|
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
|
||||||
|
@ -88,4 +90,8 @@ class Api::BaseController < ApplicationController
|
||||||
def authorize_if_got_token!(*scopes)
|
def authorize_if_got_token!(*scopes)
|
||||||
doorkeeper_authorize!(*scopes) if doorkeeper_token
|
doorkeeper_authorize!(*scopes) if doorkeeper_token
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_cache_headers
|
||||||
|
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
class Api::V1::CustomEmojisController < Api::BaseController
|
class Api::V1::CustomEmojisController < Api::BaseController
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
|
skip_before_action :set_cache_headers
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
|
render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
|
||||||
ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)
|
ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class Api::V1::Instances::ActivityController < Api::BaseController
|
class Api::V1::Instances::ActivityController < Api::BaseController
|
||||||
before_action :require_enabled_api!
|
before_action :require_enabled_api!
|
||||||
|
skip_before_action :set_cache_headers
|
||||||
|
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class Api::V1::Instances::PeersController < Api::BaseController
|
class Api::V1::Instances::PeersController < Api::BaseController
|
||||||
before_action :require_enabled_api!
|
before_action :require_enabled_api!
|
||||||
|
skip_before_action :set_cache_headers
|
||||||
|
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class Api::V1::InstancesController < Api::BaseController
|
class Api::V1::InstancesController < Api::BaseController
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
skip_before_action :set_cache_headers
|
||||||
|
|
||||||
def show
|
def show
|
||||||
render_cached_json('api:v1:instances', expires_in: 5.minutes) do
|
render_cached_json('api:v1:instances', expires_in: 5.minutes) do
|
||||||
|
|
|
@ -96,7 +96,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_invite
|
def set_invite
|
||||||
@invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
|
invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
|
||||||
|
@invite = invite&.valid_for_use? ? invite : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def determine_layout
|
def determine_layout
|
||||||
|
|
|
@ -21,7 +21,7 @@ class Settings::NotificationsController < Settings::BaseController
|
||||||
|
|
||||||
def user_settings_params
|
def user_settings_params
|
||||||
params.require(:user).permit(
|
params.require(:user).permit(
|
||||||
notification_emails: %i(follow follow_request reblog favourite mention digest report),
|
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
|
||||||
interactions: %i(must_be_follower must_be_following must_be_following_dm)
|
interactions: %i(must_be_follower must_be_following must_be_following_dm)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
@ -97,7 +97,7 @@ export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done =
|
||||||
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
|
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
|
||||||
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
|
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
|
||||||
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
|
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
|
||||||
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
|
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
||||||
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
||||||
|
|
||||||
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
|
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import { isIOS } from 'flavours/glitch/util/is_mobile';
|
import { isIOS } from 'flavours/glitch/util/is_mobile';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
|
import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
|
||||||
|
import { decode } from 'blurhash';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
hidden: {
|
hidden: {
|
||||||
|
@ -41,6 +42,7 @@ class Item extends React.PureComponent {
|
||||||
letterbox: PropTypes.bool,
|
letterbox: PropTypes.bool,
|
||||||
onClick: PropTypes.func.isRequired,
|
onClick: PropTypes.func.isRequired,
|
||||||
displayWidth: PropTypes.number,
|
displayWidth: PropTypes.number,
|
||||||
|
visible: PropTypes.bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -49,6 +51,10 @@ class Item extends React.PureComponent {
|
||||||
size: 1,
|
size: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
loaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
handleMouseEnter = (e) => {
|
handleMouseEnter = (e) => {
|
||||||
if (this.hoverToPlay()) {
|
if (this.hoverToPlay()) {
|
||||||
e.target.play();
|
e.target.play();
|
||||||
|
@ -82,8 +88,40 @@ class Item extends React.PureComponent {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
if (this.props.attachment.get('blurhash')) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate (prevProps) {
|
||||||
|
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_decode () {
|
||||||
|
const hash = this.props.attachment.get('blurhash');
|
||||||
|
const pixels = decode(hash, 32, 32);
|
||||||
|
|
||||||
|
if (pixels) {
|
||||||
|
const ctx = this.canvas.getContext('2d');
|
||||||
|
const imageData = new ImageData(pixels, 32, 32);
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanvasRef = c => {
|
||||||
|
this.canvas = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImageLoad = () => {
|
||||||
|
this.setState({ loaded: true });
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { attachment, index, size, standalone, letterbox, displayWidth } = this.props;
|
const { attachment, index, size, standalone, letterbox, displayWidth, visible } = this.props;
|
||||||
|
|
||||||
let width = 50;
|
let width = 50;
|
||||||
let height = 100;
|
let height = 100;
|
||||||
|
@ -136,12 +174,20 @@ class Item extends React.PureComponent {
|
||||||
|
|
||||||
let thumbnail = '';
|
let thumbnail = '';
|
||||||
|
|
||||||
if (attachment.get('type') === 'image') {
|
if (attachment.get('type') === 'unknown') {
|
||||||
|
return (
|
||||||
|
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||||
|
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
|
||||||
|
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (attachment.get('type') === 'image') {
|
||||||
const previewUrl = attachment.get('preview_url');
|
const previewUrl = attachment.get('preview_url');
|
||||||
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
||||||
|
|
||||||
const originalUrl = attachment.get('url');
|
const originalUrl = attachment.get('url');
|
||||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||||
|
|
||||||
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
||||||
|
|
||||||
|
@ -168,6 +214,7 @@ class Item extends React.PureComponent {
|
||||||
alt={attachment.get('description')}
|
alt={attachment.get('description')}
|
||||||
title={attachment.get('description')}
|
title={attachment.get('description')}
|
||||||
style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }}
|
style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }}
|
||||||
|
onLoad={this.handleImageLoad}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@ -197,7 +244,8 @@ class Item extends React.PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('media-gallery__item', { standalone, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
<div className={classNames('media-gallery__item', { standalone, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||||
{thumbnail}
|
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
|
||||||
|
{visible && thumbnail}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -257,6 +305,7 @@ export default class MediaGallery extends React.PureComponent {
|
||||||
this.node = node;
|
this.node = node;
|
||||||
if (node && node.offsetWidth && node.offsetWidth != this.state.width) {
|
if (node && node.offsetWidth && node.offsetWidth != this.state.width) {
|
||||||
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
width: node.offsetWidth,
|
width: node.offsetWidth,
|
||||||
});
|
});
|
||||||
|
@ -275,7 +324,7 @@ export default class MediaGallery extends React.PureComponent {
|
||||||
|
|
||||||
const width = this.state.width || defaultWidth;
|
const width = this.state.width || defaultWidth;
|
||||||
|
|
||||||
let children;
|
let children, spoilerButton;
|
||||||
|
|
||||||
const style = {};
|
const style = {};
|
||||||
|
|
||||||
|
@ -289,40 +338,32 @@ export default class MediaGallery extends React.PureComponent {
|
||||||
return (<div className={computedClass} ref={this.handleRef}></div>);
|
return (<div className={computedClass} ref={this.handleRef}></div>);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!visible) {
|
if (this.isStandaloneEligible()) {
|
||||||
let warning = <FormattedMessage {...(sensitive ? messages.warning : messages.hidden)} />;
|
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||||
|
} else {
|
||||||
|
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible} />);
|
||||||
|
}
|
||||||
|
|
||||||
children = (
|
if (visible) {
|
||||||
<button className='media-spoiler' type='button' onClick={this.handleOpen}>
|
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
||||||
<span className='media-spoiler__warning'>{warning}</span>
|
} else {
|
||||||
<span className='media-spoiler__trigger'><FormattedMessage {...messages.toggle} /></span>
|
spoilerButton = (
|
||||||
|
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||||
|
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
if (this.isStandaloneEligible()) {
|
|
||||||
children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} displayWidth={width} />;
|
|
||||||
} else {
|
|
||||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} />);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={computedClass} style={style} ref={this.handleRef}>
|
<div className={computedClass} style={style} ref={this.handleRef}>
|
||||||
{visible ? (
|
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
|
||||||
<div className='sensitive-info'>
|
{spoilerButton}
|
||||||
<IconButton
|
{visible && sensitive && (
|
||||||
icon='eye'
|
<span className='sensitive-marker'>
|
||||||
onClick={this.handleOpen}
|
<FormattedMessage {...messages.sensitive} />
|
||||||
overlay
|
</span>
|
||||||
title={intl.formatMessage(messages.toggle_visible)}
|
)}
|
||||||
/>
|
</div>
|
||||||
{sensitive ? (
|
|
||||||
<span className='sensitive-marker'>
|
|
||||||
<FormattedMessage {...messages.sensitive} />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -479,6 +479,7 @@ export default class Status extends ImmutablePureComponent {
|
||||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||||
{Component => (<Component
|
{Component => (<Component
|
||||||
preview={video.get('preview_url')}
|
preview={video.get('preview_url')}
|
||||||
|
blurhash={video.get('blurhash')}
|
||||||
src={video.get('url')}
|
src={video.get('url')}
|
||||||
alt={video.get('description')}
|
alt={video.get('description')}
|
||||||
inline
|
inline
|
||||||
|
|
|
@ -1,69 +1,148 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Permalink from 'flavours/glitch/components/permalink';
|
import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
|
||||||
import { displayMedia } from 'flavours/glitch/util/initial_state';
|
import classNames from 'classnames';
|
||||||
|
import { decode } from 'blurhash';
|
||||||
|
import { isIOS } from 'flavours/glitch/util/is_mobile';
|
||||||
|
|
||||||
export default class MediaItem extends ImmutablePureComponent {
|
export default class MediaItem extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
attachment: ImmutablePropTypes.map.isRequired,
|
||||||
|
displayWidth: PropTypes.number.isRequired,
|
||||||
|
onOpenMedia: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
|
visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
|
||||||
|
loaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClick = () => {
|
componentDidMount () {
|
||||||
if (!this.state.visible) {
|
if (this.props.attachment.get('blurhash')) {
|
||||||
this.setState({ visible: true });
|
this._decode();
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
componentDidUpdate (prevProps) {
|
||||||
|
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_decode () {
|
||||||
|
const hash = this.props.attachment.get('blurhash');
|
||||||
|
const pixels = decode(hash, 32, 32);
|
||||||
|
|
||||||
|
if (pixels) {
|
||||||
|
const ctx = this.canvas.getContext('2d');
|
||||||
|
const imageData = new ImageData(pixels, 32, 32);
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanvasRef = c => {
|
||||||
|
this.canvas = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImageLoad = () => {
|
||||||
|
this.setState({ loaded: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseEnter = e => {
|
||||||
|
if (this.hoverToPlay()) {
|
||||||
|
e.target.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseLeave = e => {
|
||||||
|
if (this.hoverToPlay()) {
|
||||||
|
e.target.pause();
|
||||||
|
e.target.currentTime = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverToPlay () {
|
||||||
|
return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick = e => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (this.state.visible) {
|
||||||
|
this.props.onOpenMedia(this.props.attachment);
|
||||||
|
} else {
|
||||||
|
this.setState({ visible: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media } = this.props;
|
const { attachment, displayWidth } = this.props;
|
||||||
const { visible } = this.state;
|
const { visible, loaded } = this.state;
|
||||||
const status = media.get('status');
|
|
||||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
|
||||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
|
||||||
const x = ((focusX / 2) + .5) * 100;
|
|
||||||
const y = ((focusY / -2) + .5) * 100;
|
|
||||||
const style = {};
|
|
||||||
|
|
||||||
let label, icon, title;
|
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
|
||||||
|
const height = width;
|
||||||
|
const status = attachment.get('status');
|
||||||
|
const title = status.get('spoiler_text') || attachment.get('description');
|
||||||
|
|
||||||
if (media.get('type') === 'gifv') {
|
let thumbnail = '';
|
||||||
label = <span className='media-gallery__gifv__label'>GIF</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visible) {
|
if (attachment.get('type') === 'unknown') {
|
||||||
style.backgroundImage = `url(${media.get('preview_url')})`;
|
// Skip
|
||||||
style.backgroundPosition = `${x}% ${y}%`;
|
} else if (attachment.get('type') === 'image') {
|
||||||
title = media.get('description');
|
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
||||||
} else {
|
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
||||||
icon = (
|
const x = ((focusX / 2) + .5) * 100;
|
||||||
<span className='account-gallery__item__icons'>
|
const y = ((focusY / -2) + .5) * 100;
|
||||||
<i className='fa fa-eye-slash' />
|
|
||||||
</span>
|
thumbnail = (
|
||||||
|
<img
|
||||||
|
src={attachment.get('preview_url')}
|
||||||
|
alt={attachment.get('description')}
|
||||||
|
title={attachment.get('description')}
|
||||||
|
style={{ objectPosition: `${x}% ${y}%` }}
|
||||||
|
onLoad={this.handleImageLoad}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
|
||||||
|
const autoPlay = !isIOS() && autoPlayGif;
|
||||||
|
|
||||||
|
thumbnail = (
|
||||||
|
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||||
|
<video
|
||||||
|
className='media-gallery__item-gifv-thumbnail'
|
||||||
|
aria-label={attachment.get('description')}
|
||||||
|
title={attachment.get('description')}
|
||||||
|
role='application'
|
||||||
|
src={attachment.get('url')}
|
||||||
|
onMouseEnter={this.handleMouseEnter}
|
||||||
|
onMouseLeave={this.handleMouseLeave}
|
||||||
|
autoPlay={autoPlay}
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
<span className='media-gallery__gifv__label'>GIF</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
title = status.get('spoiler_text') || media.get('description');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const icon = (
|
||||||
|
<span className='account-gallery__item__icons'>
|
||||||
|
<i className='fa fa-eye-slash' />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account-gallery__item'>
|
<div className='account-gallery__item' style={{ width, height }}>
|
||||||
<Permalink
|
<a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick} title={title}>
|
||||||
to={`/statuses/${status.get('id')}`}
|
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
|
||||||
href={status.get('url')}
|
{visible ? thumbnail : icon}
|
||||||
style={style}
|
</a>
|
||||||
title={title}
|
|
||||||
onInterceptClick={this.handleClick}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
</Permalink>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,12 +14,13 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container
|
||||||
import { ScrollContainer } from 'react-router-scroll-4';
|
import { ScrollContainer } from 'react-router-scroll-4';
|
||||||
import LoadMore from 'flavours/glitch/components/load_more';
|
import LoadMore from 'flavours/glitch/components/load_more';
|
||||||
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
|
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
|
||||||
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
||||||
medias: getAccountGallery(state, props.params.accountId),
|
attachments: getAccountGallery(state, props.params.accountId),
|
||||||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
|
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
|
||||||
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
|
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
|
||||||
});
|
});
|
||||||
|
|
||||||
class LoadMoreMedia extends ImmutablePureComponent {
|
class LoadMoreMedia extends ImmutablePureComponent {
|
||||||
|
@ -50,12 +51,16 @@ export default class AccountGallery extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
params: PropTypes.object.isRequired,
|
params: PropTypes.object.isRequired,
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
medias: ImmutablePropTypes.list.isRequired,
|
attachments: ImmutablePropTypes.list.isRequired,
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
isAccount: PropTypes.bool,
|
isAccount: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
width: 323,
|
||||||
|
};
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
|
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
|
||||||
|
@ -74,11 +79,11 @@ export default class AccountGallery extends ImmutablePureComponent {
|
||||||
|
|
||||||
handleScrollToBottom = () => {
|
handleScrollToBottom = () => {
|
||||||
if (this.props.hasMore) {
|
if (this.props.hasMore) {
|
||||||
this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
|
this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleScroll = (e) => {
|
handleScroll = e => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||||
const offset = scrollHeight - scrollTop - clientHeight;
|
const offset = scrollHeight - scrollTop - clientHeight;
|
||||||
|
|
||||||
|
@ -91,7 +96,7 @@ export default class AccountGallery extends ImmutablePureComponent {
|
||||||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
|
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleLoadOlder = (e) => {
|
handleLoadOlder = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.handleScrollToBottom();
|
this.handleScrollToBottom();
|
||||||
}
|
}
|
||||||
|
@ -101,12 +106,30 @@ export default class AccountGallery extends ImmutablePureComponent {
|
||||||
return !(location.state && location.state.mastodonModalOpen);
|
return !(location.state && location.state.mastodonModalOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
setRef = c => {
|
setColumnRef = c => {
|
||||||
this.column = c;
|
this.column = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOpenMedia = attachment => {
|
||||||
|
if (attachment.get('type') === 'video') {
|
||||||
|
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
|
||||||
|
} else {
|
||||||
|
const media = attachment.getIn(['status', 'media_attachments']);
|
||||||
|
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
|
||||||
|
|
||||||
|
this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRef = c => {
|
||||||
|
if (c) {
|
||||||
|
this.setState({ width: c.offsetWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { medias, isLoading, hasMore, isAccount } = this.props;
|
const { attachments, isLoading, hasMore, isAccount } = this.props;
|
||||||
|
const { width } = this.state;
|
||||||
|
|
||||||
if (!isAccount) {
|
if (!isAccount) {
|
||||||
return (
|
return (
|
||||||
|
@ -116,9 +139,7 @@ export default class AccountGallery extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let loadOlder = null;
|
if (!attachments && isLoading) {
|
||||||
|
|
||||||
if (!medias && isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
|
@ -126,35 +147,31 @@ export default class AccountGallery extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasMore && !(isLoading && medias.size === 0)) {
|
let loadOlder = null;
|
||||||
|
|
||||||
|
if (hasMore && !(isLoading && attachments.size === 0)) {
|
||||||
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
|
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column ref={this.setRef}>
|
<Column ref={this.setColumnRef}>
|
||||||
<ProfileColumnHeader onClick={this.handleHeaderClick} />
|
<ProfileColumnHeader onClick={this.handleHeaderClick} />
|
||||||
|
|
||||||
<ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||||
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
|
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
|
||||||
<HeaderContainer accountId={this.props.params.accountId} />
|
<HeaderContainer accountId={this.props.params.accountId} />
|
||||||
|
|
||||||
<div role='feed' className='account-gallery__container'>
|
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
|
||||||
{medias.map((media, index) => media === null ? (
|
{attachments.map((attachment, index) => attachment === null ? (
|
||||||
<LoadMoreMedia
|
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
|
||||||
key={'more:' + medias.getIn(index + 1, 'id')}
|
|
||||||
maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
|
|
||||||
onLoadMore={this.handleLoadMore}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<MediaItem
|
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
|
||||||
key={media.get('id')}
|
|
||||||
media={media}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{loadOlder}
|
{loadOlder}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && medias.size === 0 && (
|
{isLoading && attachments.size === 0 && (
|
||||||
<div className='scrollable__append'>
|
<div className='scrollable__append'>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -65,10 +65,6 @@ const messages = defineMessages({
|
||||||
defaultMessage: 'Public',
|
defaultMessage: 'Public',
|
||||||
id: 'privacy.public.short',
|
id: 'privacy.public.short',
|
||||||
},
|
},
|
||||||
sensitive: {
|
|
||||||
defaultMessage: 'Mark media as sensitive',
|
|
||||||
id: 'compose_form.sensitive',
|
|
||||||
},
|
|
||||||
spoiler: {
|
spoiler: {
|
||||||
defaultMessage: 'Hide text behind warning',
|
defaultMessage: 'Hide text behind warning',
|
||||||
id: 'compose_form.spoiler',
|
id: 'compose_form.spoiler',
|
||||||
|
@ -116,7 +112,6 @@ class ComposerOptions extends ImmutablePureComponent {
|
||||||
hasPoll: PropTypes.bool,
|
hasPoll: PropTypes.bool,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
onChangeAdvancedOption: PropTypes.func,
|
onChangeAdvancedOption: PropTypes.func,
|
||||||
onChangeSensitivity: PropTypes.func,
|
|
||||||
onChangeVisibility: PropTypes.func,
|
onChangeVisibility: PropTypes.func,
|
||||||
onTogglePoll: PropTypes.func,
|
onTogglePoll: PropTypes.func,
|
||||||
onDoodleOpen: PropTypes.func,
|
onDoodleOpen: PropTypes.func,
|
||||||
|
@ -126,7 +121,6 @@ class ComposerOptions extends ImmutablePureComponent {
|
||||||
onUpload: PropTypes.func,
|
onUpload: PropTypes.func,
|
||||||
privacy: PropTypes.string,
|
privacy: PropTypes.string,
|
||||||
resetFileKey: PropTypes.number,
|
resetFileKey: PropTypes.number,
|
||||||
sensitive: PropTypes.bool,
|
|
||||||
spoiler: PropTypes.bool,
|
spoiler: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -175,7 +169,6 @@ class ComposerOptions extends ImmutablePureComponent {
|
||||||
hasPoll,
|
hasPoll,
|
||||||
intl,
|
intl,
|
||||||
onChangeAdvancedOption,
|
onChangeAdvancedOption,
|
||||||
onChangeSensitivity,
|
|
||||||
onChangeVisibility,
|
onChangeVisibility,
|
||||||
onTogglePoll,
|
onTogglePoll,
|
||||||
onModalClose,
|
onModalClose,
|
||||||
|
@ -183,7 +176,6 @@ class ComposerOptions extends ImmutablePureComponent {
|
||||||
onToggleSpoiler,
|
onToggleSpoiler,
|
||||||
privacy,
|
privacy,
|
||||||
resetFileKey,
|
resetFileKey,
|
||||||
sensitive,
|
|
||||||
spoiler,
|
spoiler,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
@ -264,39 +256,6 @@ class ComposerOptions extends ImmutablePureComponent {
|
||||||
title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
|
title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Motion
|
|
||||||
defaultStyle={{ scale: 0.87 }}
|
|
||||||
style={{
|
|
||||||
scale: spring(hasMedia ? 1 : 0.87, {
|
|
||||||
stiffness: 200,
|
|
||||||
damping: 3,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ scale }) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: hasMedia ? null : 'none',
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
active={sensitive}
|
|
||||||
className='sensitive'
|
|
||||||
disabled={spoiler}
|
|
||||||
icon={sensitive ? 'eye-slash' : 'eye'}
|
|
||||||
inverted
|
|
||||||
onClick={onChangeSensitivity}
|
|
||||||
size={18}
|
|
||||||
style={{
|
|
||||||
height: null,
|
|
||||||
lineHeight: null,
|
|
||||||
}}
|
|
||||||
title={intl.formatMessage(messages.sensitive)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Motion>
|
|
||||||
<hr />
|
<hr />
|
||||||
<Dropdown
|
<Dropdown
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import UploadProgressContainer from '../containers/upload_progress_container';
|
import UploadProgressContainer from '../containers/upload_progress_container';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import UploadContainer from '../containers/upload_container';
|
import UploadContainer from '../containers/upload_container';
|
||||||
|
import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
||||||
|
|
||||||
export default class UploadForm extends ImmutablePureComponent {
|
export default class UploadForm extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -23,6 +24,8 @@ export default class UploadForm extends ImmutablePureComponent {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!mediaIds.isEmpty() && <SensitiveButtonContainer />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { connect } from 'react-redux';
|
||||||
import Options from '../components/options';
|
import Options from '../components/options';
|
||||||
import {
|
import {
|
||||||
changeComposeAdvancedOption,
|
changeComposeAdvancedOption,
|
||||||
changeComposeSensitivity,
|
|
||||||
} from 'flavours/glitch/actions/compose';
|
} from 'flavours/glitch/actions/compose';
|
||||||
import { addPoll, removePoll } from 'flavours/glitch/actions/compose';
|
import { addPoll, removePoll } from 'flavours/glitch/actions/compose';
|
||||||
import { closeModal, openModal } from 'flavours/glitch/actions/modal';
|
import { closeModal, openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
@ -27,10 +26,6 @@ const mapDispatchToProps = (dispatch) => ({
|
||||||
dispatch(changeComposeAdvancedOption(option, value));
|
dispatch(changeComposeAdvancedOption(option, value));
|
||||||
},
|
},
|
||||||
|
|
||||||
onChangeSensitivity() {
|
|
||||||
dispatch(changeComposeSensitivity());
|
|
||||||
},
|
|
||||||
|
|
||||||
onTogglePoll() {
|
onTogglePoll() {
|
||||||
dispatch((_, getState) => {
|
dispatch((_, getState) => {
|
||||||
if (getState().getIn(['compose', 'poll'])) {
|
if (getState().getIn(['compose', 'poll'])) {
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
|
||||||
|
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
import Icon from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
|
||||||
|
unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => {
|
||||||
|
const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
|
||||||
|
const spoilerText = state.getIn(['compose', 'spoiler_text']);
|
||||||
|
return {
|
||||||
|
active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0),
|
||||||
|
disabled: state.getIn(['compose', 'spoiler']),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
|
||||||
|
onClick () {
|
||||||
|
dispatch(changeComposeSensitivity());
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
class SensitiveButton extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
active: PropTypes.bool,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
onClick: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { active, disabled, onClick, intl } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='compose-form__sensitive-button'>
|
||||||
|
<button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
|
||||||
|
<Icon icon='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
|
|
@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent {
|
||||||
{Component => (
|
{Component => (
|
||||||
<Component
|
<Component
|
||||||
preview={video.get('preview_url')}
|
preview={video.get('preview_url')}
|
||||||
|
blurhash={video.get('blurhash')}
|
||||||
src={video.get('url')}
|
src={video.get('url')}
|
||||||
alt={video.get('description')}
|
alt={video.get('description')}
|
||||||
width={239}
|
width={239}
|
||||||
|
|
|
@ -134,6 +134,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||||
media = (
|
media = (
|
||||||
<Video
|
<Video
|
||||||
preview={video.get('preview_url')}
|
preview={video.get('preview_url')}
|
||||||
|
blurhash={video.get('blurhash')}
|
||||||
src={video.get('url')}
|
src={video.get('url')}
|
||||||
alt={video.get('description')}
|
alt={video.get('description')}
|
||||||
inline
|
inline
|
||||||
|
|
|
@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
|
||||||
import Video from 'flavours/glitch/features/video';
|
import Video from 'flavours/glitch/features/video';
|
||||||
import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player';
|
import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import IconButton from 'flavours/glitch/components/icon_button';
|
import IconButton from 'flavours/glitch/components/icon_button';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import ImageLoader from './image_loader';
|
import ImageLoader from './image_loader';
|
||||||
|
@ -19,8 +19,13 @@ const messages = defineMessages({
|
||||||
@injectIntl
|
@injectIntl
|
||||||
export default class MediaModal extends ImmutablePureComponent {
|
export default class MediaModal extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.list.isRequired,
|
media: ImmutablePropTypes.list.isRequired,
|
||||||
|
status: ImmutablePropTypes.map,
|
||||||
index: PropTypes.number.isRequired,
|
index: PropTypes.number.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
|
@ -81,8 +86,15 @@ export default class MediaModal extends ImmutablePureComponent {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleStatusClick = e => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, intl, onClose } = this.props;
|
const { media, status, intl, onClose } = this.props;
|
||||||
const { navigationHidden } = this.state;
|
const { navigationHidden } = this.state;
|
||||||
|
|
||||||
const index = this.getIndex();
|
const index = this.getIndex();
|
||||||
|
@ -123,6 +135,7 @@ export default class MediaModal extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<Video
|
<Video
|
||||||
preview={image.get('preview_url')}
|
preview={image.get('preview_url')}
|
||||||
|
blurhash={image.get('blurhash')}
|
||||||
src={image.get('url')}
|
src={image.get('url')}
|
||||||
width={image.get('width')}
|
width={image.get('width')}
|
||||||
height={image.get('height')}
|
height={image.get('height')}
|
||||||
|
@ -185,10 +198,19 @@ export default class MediaModal extends ImmutablePureComponent {
|
||||||
{content}
|
{content}
|
||||||
</ReactSwipeableViews>
|
</ReactSwipeableViews>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={navigationClassName}>
|
<div className={navigationClassName}>
|
||||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
|
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
|
||||||
|
|
||||||
{leftNav}
|
{leftNav}
|
||||||
{rightNav}
|
{rightNav}
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
|
||||||
|
<a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ul className='media-modal__pagination'>
|
<ul className='media-modal__pagination'>
|
||||||
{pagination}
|
{pagination}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -3,26 +3,43 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Video from 'flavours/glitch/features/video';
|
import Video from 'flavours/glitch/features/video';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export default class VideoModal extends ImmutablePureComponent {
|
export default class VideoModal extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
|
status: ImmutablePropTypes.map,
|
||||||
time: PropTypes.number,
|
time: PropTypes.number,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleStatusClick = e => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, time, onClose } = this.props;
|
const { media, status, time, onClose } = this.props;
|
||||||
|
|
||||||
|
const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal video-modal'>
|
<div className='modal-root__modal video-modal'>
|
||||||
<div>
|
<div>
|
||||||
<Video
|
<Video
|
||||||
preview={media.get('preview_url')}
|
preview={media.get('preview_url')}
|
||||||
|
blurhash={media.get('blurhash')}
|
||||||
src={media.get('url')}
|
src={media.get('url')}
|
||||||
startTime={time}
|
startTime={time}
|
||||||
onCloseVideo={onClose}
|
onCloseVideo={onClose}
|
||||||
|
link={link}
|
||||||
detailed
|
detailed
|
||||||
alt={media.get('description')}
|
alt={media.get('description')}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { throttle } from 'lodash';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen';
|
import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen';
|
||||||
import { displayMedia } from 'flavours/glitch/util/initial_state';
|
import { displayMedia } from 'flavours/glitch/util/initial_state';
|
||||||
|
import { decode } from 'blurhash';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||||
|
@ -104,6 +105,8 @@ export default class Video extends React.PureComponent {
|
||||||
preventPlayback: PropTypes.bool,
|
preventPlayback: PropTypes.bool,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
cacheWidth: PropTypes.func,
|
cacheWidth: PropTypes.func,
|
||||||
|
blurhash: PropTypes.string,
|
||||||
|
link: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -147,6 +150,7 @@ export default class Video extends React.PureComponent {
|
||||||
|
|
||||||
setVideoRef = c => {
|
setVideoRef = c => {
|
||||||
this.video = c;
|
this.video = c;
|
||||||
|
|
||||||
if (this.video) {
|
if (this.video) {
|
||||||
this.setState({ volume: this.video.volume, muted: this.video.muted });
|
this.setState({ volume: this.video.volume, muted: this.video.muted });
|
||||||
}
|
}
|
||||||
|
@ -160,6 +164,10 @@ export default class Video extends React.PureComponent {
|
||||||
this.volume = c;
|
this.volume = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCanvasRef = c => {
|
||||||
|
this.canvas = c;
|
||||||
|
}
|
||||||
|
|
||||||
handleMouseDownRoot = e => {
|
handleMouseDownRoot = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -181,7 +189,6 @@ export default class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleVolumeMouseDown = e => {
|
handleVolumeMouseDown = e => {
|
||||||
|
|
||||||
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||||
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||||
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
||||||
|
@ -201,7 +208,6 @@ export default class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseVolSlide = throttle(e => {
|
handleMouseVolSlide = throttle(e => {
|
||||||
|
|
||||||
const rect = this.volume.getBoundingClientRect();
|
const rect = this.volume.getBoundingClientRect();
|
||||||
const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
|
const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
|
||||||
|
|
||||||
|
@ -272,6 +278,10 @@ export default class Video extends React.PureComponent {
|
||||||
document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
|
document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
|
||||||
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
|
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
|
||||||
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
||||||
|
|
||||||
|
if (this.props.blurhash) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
|
@ -293,6 +303,24 @@ export default class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidUpdate (prevProps) {
|
||||||
|
if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_decode () {
|
||||||
|
const hash = this.props.blurhash;
|
||||||
|
const pixels = decode(hash, 32, 32);
|
||||||
|
|
||||||
|
if (pixels) {
|
||||||
|
const ctx = this.canvas.getContext('2d');
|
||||||
|
const imageData = new ImageData(pixels, 32, 32);
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleFullscreenChange = () => {
|
handleFullscreenChange = () => {
|
||||||
this.setState({ fullscreen: isFullscreen() });
|
this.setState({ fullscreen: isFullscreen() });
|
||||||
}
|
}
|
||||||
|
@ -337,6 +365,7 @@ export default class Video extends React.PureComponent {
|
||||||
|
|
||||||
handleOpenVideo = () => {
|
handleOpenVideo = () => {
|
||||||
const { src, preview, width, height, alt } = this.props;
|
const { src, preview, width, height, alt } = this.props;
|
||||||
|
|
||||||
const media = fromJS({
|
const media = fromJS({
|
||||||
type: 'video',
|
type: 'video',
|
||||||
url: src,
|
url: src,
|
||||||
|
@ -356,7 +385,7 @@ export default class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive } = this.props;
|
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link } = this.props;
|
||||||
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
||||||
const progress = (currentTime / duration) * 100;
|
const progress = (currentTime / duration) * 100;
|
||||||
const playerStyle = {};
|
const playerStyle = {};
|
||||||
|
@ -385,6 +414,7 @@ export default class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
let preload;
|
let preload;
|
||||||
|
|
||||||
if (startTime || fullscreen || dragging) {
|
if (startTime || fullscreen || dragging) {
|
||||||
preload = 'auto';
|
preload = 'auto';
|
||||||
} else if (detailed) {
|
} else if (detailed) {
|
||||||
|
@ -403,7 +433,9 @@ export default class Video extends React.PureComponent {
|
||||||
onMouseDown={this.handleMouseDownRoot}
|
onMouseDown={this.handleMouseDownRoot}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<video
|
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
|
||||||
|
|
||||||
|
{revealed && <video
|
||||||
ref={this.setVideoRef}
|
ref={this.setVideoRef}
|
||||||
src={src}
|
src={src}
|
||||||
poster={preview}
|
poster={preview}
|
||||||
|
@ -423,12 +455,13 @@ export default class Video extends React.PureComponent {
|
||||||
onLoadedData={this.handleLoadedData}
|
onLoadedData={this.handleLoadedData}
|
||||||
onProgress={this.handleProgress}
|
onProgress={this.handleProgress}
|
||||||
onVolumeChange={this.handleVolumeChange}
|
onVolumeChange={this.handleVolumeChange}
|
||||||
/>
|
/>}
|
||||||
|
|
||||||
<button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
|
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
|
||||||
<span className='video-player__spoiler__title'>{warning}</span>
|
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||||
<span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
||||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
||||||
|
@ -455,17 +488,19 @@ export default class Video extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(detailed || fullscreen) &&
|
{(detailed || fullscreen) && (
|
||||||
<span>
|
<span>
|
||||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||||
<span className='video-player__time-sep'>/</span>
|
<span className='video-player__time-sep'>/</span>
|
||||||
<span className='video-player__time-total'>{formatTime(duration)}</span>
|
<span className='video-player__time-total'>{formatTime(duration)}</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
)}
|
||||||
|
|
||||||
|
{link && <span className='video-player__link'>{link}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='video-player__buttons right'>
|
<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' /></button>}
|
{!onCloseVideo && <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>}
|
{(!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>}
|
{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>
|
<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>
|
||||||
|
|
|
@ -50,6 +50,7 @@ $content-width: 840px;
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 200ms linear;
|
transition: all 200ms linear;
|
||||||
|
transition-property: color, background-color;
|
||||||
border-radius: 4px 0 0 4px;
|
border-radius: 4px 0 0 4px;
|
||||||
|
|
||||||
i.fa {
|
i.fa {
|
||||||
|
@ -60,6 +61,7 @@ $content-width: 840px;
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
background-color: darken($ui-base-color, 5%);
|
background-color: darken($ui-base-color, 5%);
|
||||||
transition: all 100ms linear;
|
transition: all 100ms linear;
|
||||||
|
transition-property: color, background-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
|
|
|
@ -331,54 +331,18 @@
|
||||||
|
|
||||||
.account-gallery__container {
|
.account-gallery__container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 2px;
|
padding: 4px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-gallery__item {
|
.account-gallery__item {
|
||||||
flex-grow: 1;
|
border: none;
|
||||||
width: 50%;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
&::before {
|
overflow: hidden;
|
||||||
content: "";
|
margin: 2px;
|
||||||
display: block;
|
|
||||||
padding-top: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: block;
|
|
||||||
width: calc(100% - 4px);
|
|
||||||
height: calc(100% - 4px);
|
|
||||||
margin: 2px;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
background-color: $base-overlay-background;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
position: absolute;
|
|
||||||
color: $ui-primary-color;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:active,
|
|
||||||
&:focus {
|
|
||||||
outline: 0;
|
|
||||||
color: $ui-secondary-color;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba($base-overlay-background, 0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icons {
|
&__icons {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -57,6 +57,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compose-form__sensitive-button {
|
||||||
|
padding: 10px;
|
||||||
|
padding-top: 0;
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.composer--reply {
|
.composer--reply {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
|
@ -125,6 +125,7 @@
|
||||||
cursor: default;
|
cursor: default;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: all 100ms linear;
|
transition: all 100ms linear;
|
||||||
|
transition-property: color, transform, opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fa-search {
|
.fa-search {
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
transition: all 100ms ease-in;
|
transition: all 100ms ease-in;
|
||||||
|
transition-property: background-color;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|
||||||
|
@ -33,6 +34,7 @@
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: lighten($ui-highlight-color, 7%);
|
background-color: lighten($ui-highlight-color, 7%);
|
||||||
transition: all 200ms ease-out;
|
transition: all 200ms ease-out;
|
||||||
|
transition-property: background-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--destructive {
|
&--destructive {
|
||||||
|
@ -564,6 +566,7 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-bottom: 2px solid lighten($ui-base-color, 8%);
|
border-bottom: 2px solid lighten($ui-base-color, 8%);
|
||||||
transition: all 200ms linear;
|
transition: all 200ms linear;
|
||||||
|
transition-property: background;
|
||||||
|
|
||||||
.fa {
|
.fa {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
@ -581,6 +584,7 @@
|
||||||
@include multi-columns('screen and (min-width: 631px)') {
|
@include multi-columns('screen and (min-width: 631px)') {
|
||||||
background: lighten($ui-base-color, 14%);
|
background: lighten($ui-base-color, 14%);
|
||||||
transition: all 100ms linear;
|
transition: all 100ms linear;
|
||||||
|
transition-property: background;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -664,7 +668,7 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
background-color: $ui-base-color;
|
background-color: $ui-base-color;
|
||||||
transition: all 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
|
.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
|
||||||
|
@ -717,7 +721,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle-thumb {
|
.react-toggle-thumb {
|
||||||
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 1px;
|
top: 1px;
|
||||||
left: 1px;
|
left: 1px;
|
||||||
|
@ -728,6 +731,7 @@
|
||||||
background-color: darken($simple-background-color, 2%);
|
background-color: darken($simple-background-color, 2%);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
|
transition-property: border-color, left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle--checked .react-toggle-thumb {
|
.react-toggle--checked .react-toggle-thumb {
|
||||||
|
@ -1066,15 +1070,50 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.spoiler-button {
|
.spoiler-button {
|
||||||
display: none;
|
top: 0;
|
||||||
left: 4px;
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
|
|
||||||
top: 4px;
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
&.spoiler-button--visible {
|
&--minified {
|
||||||
|
display: flex;
|
||||||
|
left: 4px;
|
||||||
|
top: 4px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__overlay {
|
||||||
display: block;
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba($base-overlay-background, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: $primary-text-color;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
.spoiler-button__overlay__label {
|
||||||
|
background: rgba($base-overlay-background, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -58,6 +58,7 @@
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
transition: opacity 0.1s ease;
|
transition: opacity 0.1s ease;
|
||||||
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__gifv {
|
.media-gallery__gifv {
|
||||||
|
@ -117,6 +118,8 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: $secondary-text-color;
|
color: $secondary-text-color;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
&,
|
&,
|
||||||
img {
|
img {
|
||||||
|
@ -131,6 +134,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-gallery__preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 0;
|
||||||
|
background: $base-overlay-background;
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.media-gallery__gifv {
|
.media-gallery__gifv {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -252,6 +270,31 @@
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-modal__meta {
|
||||||
|
text-align: center;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 20px;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&--shifted {
|
||||||
|
bottom: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $ui-secondary-color;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.media-modal__page-dot {
|
.media-modal__page-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
@ -501,6 +544,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__link {
|
||||||
|
padding: 2px 10px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $white;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__seek {
|
&__seek {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 100ms linear;
|
transition: all 100ms linear;
|
||||||
|
transition-property: transform, opacity;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
color: rgba($primary-text-color, 0.8);
|
color: rgba($primary-text-color, 0.8);
|
||||||
background: rgba($base-overlay-background, 0.5);
|
background: rgba($base-overlay-background, 0.5);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 15px;
|
line-height: 18px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
opacity: .9;
|
opacity: .9;
|
||||||
transition: opacity .1s ease;
|
transition: opacity .1s ease;
|
||||||
|
|
|
@ -705,7 +705,7 @@
|
||||||
|
|
||||||
& > div {
|
& > div {
|
||||||
background: rgba($base-shadow-color, 0.6);
|
background: rgba($base-shadow-color, 0.6);
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
padding: 12px 9px;
|
padding: 12px 9px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -716,19 +716,18 @@
|
||||||
button,
|
button,
|
||||||
a {
|
a {
|
||||||
display: inline;
|
display: inline;
|
||||||
color: $primary-text-color;
|
color: $secondary-text-color;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0 5px;
|
padding: 0 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus {
|
||||||
opacity: 1;
|
color: $primary-text-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -533,6 +533,17 @@ code {
|
||||||
color: $error-value-color;
|
color: $error-value-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: inline-block;
|
||||||
|
color: $darker-text-color;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-text-color;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
&__img {
|
&__img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 167px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
|
|
|
@ -203,8 +203,8 @@ export function uploadCompose(files) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
const uploadLimit = 4;
|
const uploadLimit = 4;
|
||||||
const media = getState().getIn(['compose', 'media_attachments']);
|
const media = getState().getIn(['compose', 'media_attachments']);
|
||||||
const total = Array.from(files).reduce((a, v) => a + v.size, 0);
|
|
||||||
const progress = new Array(files.length).fill(0);
|
const progress = new Array(files.length).fill(0);
|
||||||
|
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
|
||||||
|
|
||||||
if (files.length + media.size > uploadLimit) {
|
if (files.length + media.size > uploadLimit) {
|
||||||
dispatch(showAlert(undefined, messages.uploadErrorLimit));
|
dispatch(showAlert(undefined, messages.uploadErrorLimit));
|
||||||
|
@ -224,6 +224,8 @@ export function uploadCompose(files) {
|
||||||
resizeImage(f).then(file => {
|
resizeImage(f).then(file => {
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
data.append('file', file);
|
data.append('file', file);
|
||||||
|
// Account for disparity in size of original image and resized data
|
||||||
|
total += file.size - f.size;
|
||||||
|
|
||||||
return api(getState).post('/api/v1/media', data, {
|
return api(getState).post('/api/v1/media', data, {
|
||||||
onUploadProgress: function({ loaded }){
|
onUploadProgress: function({ loaded }){
|
||||||
|
|
|
@ -96,7 +96,7 @@ export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done =
|
||||||
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
|
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
|
||||||
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
|
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
|
||||||
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
|
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
|
||||||
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
|
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
||||||
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
||||||
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
|
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
|
||||||
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import { isIOS } from '../is_mobile';
|
import { isIOS } from '../is_mobile';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { autoPlayGif, displayMedia } from '../initial_state';
|
import { autoPlayGif, displayMedia } from '../initial_state';
|
||||||
|
import { decode } from 'blurhash';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
||||||
|
@ -21,6 +22,7 @@ class Item extends React.PureComponent {
|
||||||
size: PropTypes.number.isRequired,
|
size: PropTypes.number.isRequired,
|
||||||
onClick: PropTypes.func.isRequired,
|
onClick: PropTypes.func.isRequired,
|
||||||
displayWidth: PropTypes.number,
|
displayWidth: PropTypes.number,
|
||||||
|
visible: PropTypes.bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -29,6 +31,10 @@ class Item extends React.PureComponent {
|
||||||
size: 1,
|
size: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
loaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
handleMouseEnter = (e) => {
|
handleMouseEnter = (e) => {
|
||||||
if (this.hoverToPlay()) {
|
if (this.hoverToPlay()) {
|
||||||
e.target.play();
|
e.target.play();
|
||||||
|
@ -62,8 +68,40 @@ class Item extends React.PureComponent {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
if (this.props.attachment.get('blurhash')) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate (prevProps) {
|
||||||
|
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_decode () {
|
||||||
|
const hash = this.props.attachment.get('blurhash');
|
||||||
|
const pixels = decode(hash, 32, 32);
|
||||||
|
|
||||||
|
if (pixels) {
|
||||||
|
const ctx = this.canvas.getContext('2d');
|
||||||
|
const imageData = new ImageData(pixels, 32, 32);
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanvasRef = c => {
|
||||||
|
this.canvas = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImageLoad = () => {
|
||||||
|
this.setState({ loaded: true });
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { attachment, index, size, standalone, displayWidth } = this.props;
|
const { attachment, index, size, standalone, displayWidth, visible } = this.props;
|
||||||
|
|
||||||
let width = 50;
|
let width = 50;
|
||||||
let height = 100;
|
let height = 100;
|
||||||
|
@ -116,12 +154,20 @@ class Item extends React.PureComponent {
|
||||||
|
|
||||||
let thumbnail = '';
|
let thumbnail = '';
|
||||||
|
|
||||||
if (attachment.get('type') === 'image') {
|
if (attachment.get('type') === 'unknown') {
|
||||||
|
return (
|
||||||
|
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||||
|
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
|
||||||
|
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (attachment.get('type') === 'image') {
|
||||||
const previewUrl = attachment.get('preview_url');
|
const previewUrl = attachment.get('preview_url');
|
||||||
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
||||||
|
|
||||||
const originalUrl = attachment.get('url');
|
const originalUrl = attachment.get('url');
|
||||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||||
|
|
||||||
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
||||||
|
|
||||||
|
@ -147,6 +193,7 @@ class Item extends React.PureComponent {
|
||||||
alt={attachment.get('description')}
|
alt={attachment.get('description')}
|
||||||
title={attachment.get('description')}
|
title={attachment.get('description')}
|
||||||
style={{ objectPosition: `${x}% ${y}%` }}
|
style={{ objectPosition: `${x}% ${y}%` }}
|
||||||
|
onLoad={this.handleImageLoad}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@ -176,7 +223,8 @@ class Item extends React.PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||||
{thumbnail}
|
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
|
||||||
|
{visible && thumbnail}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent {
|
||||||
if (node /*&& this.isStandaloneEligible()*/) {
|
if (node /*&& this.isStandaloneEligible()*/) {
|
||||||
// offsetWidth triggers a layout, so only calculate when we need to
|
// offsetWidth triggers a layout, so only calculate when we need to
|
||||||
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
width: node.offsetWidth,
|
width: node.offsetWidth,
|
||||||
});
|
});
|
||||||
|
@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent {
|
||||||
|
|
||||||
const width = this.state.width || defaultWidth;
|
const width = this.state.width || defaultWidth;
|
||||||
|
|
||||||
let children;
|
let children, spoilerButton;
|
||||||
|
|
||||||
const style = {};
|
const style = {};
|
||||||
|
|
||||||
|
@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent {
|
||||||
style.height = height;
|
style.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!visible) {
|
const size = media.take(4).size;
|
||||||
let warning;
|
|
||||||
|
|
||||||
if (sensitive) {
|
if (this.isStandaloneEligible()) {
|
||||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||||
} else {
|
} else {
|
||||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
children = (
|
if (visible) {
|
||||||
<button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
|
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
||||||
<span className='media-spoiler__warning'>{warning}</span>
|
} else {
|
||||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
spoilerButton = (
|
||||||
|
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||||
|
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
const size = media.take(4).size;
|
|
||||||
|
|
||||||
if (this.isStandaloneEligible()) {
|
|
||||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
|
|
||||||
} else {
|
|
||||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||||
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
|
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
|
||||||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
|
{spoilerButton}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent {
|
||||||
if (status.get('poll')) {
|
if (status.get('poll')) {
|
||||||
media = <PollContainer pollId={status.get('poll')} />;
|
media = <PollContainer pollId={status.get('poll')} />;
|
||||||
} else if (status.get('media_attachments').size > 0) {
|
} else if (status.get('media_attachments').size > 0) {
|
||||||
if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
if (this.props.muted) {
|
||||||
media = (
|
media = (
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
compact
|
compact
|
||||||
|
@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent {
|
||||||
{Component => (
|
{Component => (
|
||||||
<Component
|
<Component
|
||||||
preview={video.get('preview_url')}
|
preview={video.get('preview_url')}
|
||||||
|
blurhash={video.get('blurhash')}
|
||||||
src={video.get('url')}
|
src={video.get('url')}
|
||||||
alt={video.get('description')}
|
alt={video.get('description')}
|
||||||
width={this.props.cachedMediaWidth}
|
width={this.props.cachedMediaWidth}
|
||||||
|
|
|
@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
|
|
||||||
handleMoveUp = (id, featured) => {
|
handleMoveUp = (id, featured) => {
|
||||||
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
|
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
|
||||||
this._selectChild(elementIndex);
|
this._selectChild(elementIndex, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMoveDown = (id, featured) => {
|
handleMoveDown = (id, featured) => {
|
||||||
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
|
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
|
||||||
this._selectChild(elementIndex);
|
this._selectChild(elementIndex, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLoadOlder = debounce(() => {
|
handleLoadOlder = debounce(() => {
|
||||||
this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
|
this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
|
||||||
}, 300, { leading: true })
|
}, 300, { leading: true })
|
||||||
|
|
||||||
_selectChild (index) {
|
_selectChild (index, align_top) {
|
||||||
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
const container = this.node.node;
|
||||||
|
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
|
if (align_top && container.scrollTop > element.offsetTop) {
|
||||||
|
element.scrollIntoView(true);
|
||||||
|
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||||
|
element.scrollIntoView(false);
|
||||||
|
}
|
||||||
element.focus();
|
element.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,62 +1,142 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Permalink from '../../../components/permalink';
|
import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
|
||||||
import { displayMedia } from '../../../initial_state';
|
import classNames from 'classnames';
|
||||||
import Icon from 'mastodon/components/icon';
|
import { decode } from 'blurhash';
|
||||||
|
import { isIOS } from 'mastodon/is_mobile';
|
||||||
|
|
||||||
export default class MediaItem extends ImmutablePureComponent {
|
export default class MediaItem extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
attachment: ImmutablePropTypes.map.isRequired,
|
||||||
|
displayWidth: PropTypes.number.isRequired,
|
||||||
|
onOpenMedia: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
|
visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
|
||||||
|
loaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClick = () => {
|
componentDidMount () {
|
||||||
if (!this.state.visible) {
|
if (this.props.attachment.get('blurhash')) {
|
||||||
this.setState({ visible: true });
|
this._decode();
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
componentDidUpdate (prevProps) {
|
||||||
|
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_decode () {
|
||||||
|
const hash = this.props.attachment.get('blurhash');
|
||||||
|
const pixels = decode(hash, 32, 32);
|
||||||
|
|
||||||
|
if (pixels) {
|
||||||
|
const ctx = this.canvas.getContext('2d');
|
||||||
|
const imageData = new ImageData(pixels, 32, 32);
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanvasRef = c => {
|
||||||
|
this.canvas = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImageLoad = () => {
|
||||||
|
this.setState({ loaded: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseEnter = e => {
|
||||||
|
if (this.hoverToPlay()) {
|
||||||
|
e.target.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseLeave = e => {
|
||||||
|
if (this.hoverToPlay()) {
|
||||||
|
e.target.pause();
|
||||||
|
e.target.currentTime = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverToPlay () {
|
||||||
|
return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick = e => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (this.state.visible) {
|
||||||
|
this.props.onOpenMedia(this.props.attachment);
|
||||||
|
} else {
|
||||||
|
this.setState({ visible: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media } = this.props;
|
const { attachment, displayWidth } = this.props;
|
||||||
const { visible } = this.state;
|
const { visible, loaded } = this.state;
|
||||||
const status = media.get('status');
|
|
||||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
|
||||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
|
||||||
const x = ((focusX / 2) + .5) * 100;
|
|
||||||
const y = ((focusY / -2) + .5) * 100;
|
|
||||||
const style = {};
|
|
||||||
|
|
||||||
let label, icon;
|
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
|
||||||
|
const height = width;
|
||||||
|
const status = attachment.get('status');
|
||||||
|
|
||||||
if (media.get('type') === 'gifv') {
|
let thumbnail = '';
|
||||||
label = <span className='media-gallery__gifv__label'>GIF</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visible) {
|
if (attachment.get('type') === 'unknown') {
|
||||||
style.backgroundImage = `url(${media.get('preview_url')})`;
|
// Skip
|
||||||
style.backgroundPosition = `${x}% ${y}%`;
|
} else if (attachment.get('type') === 'image') {
|
||||||
} else {
|
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
||||||
icon = (
|
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
||||||
<span className='account-gallery__item__icons'>
|
const x = ((focusX / 2) + .5) * 100;
|
||||||
<Icon id='eye-slash' />
|
const y = ((focusY / -2) + .5) * 100;
|
||||||
</span>
|
|
||||||
|
thumbnail = (
|
||||||
|
<img
|
||||||
|
src={attachment.get('preview_url')}
|
||||||
|
alt={attachment.get('description')}
|
||||||
|
title={attachment.get('description')}
|
||||||
|
style={{ objectPosition: `${x}% ${y}%` }}
|
||||||
|
onLoad={this.handleImageLoad}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
|
||||||
|
const autoPlay = !isIOS() && autoPlayGif;
|
||||||
|
|
||||||
|
thumbnail = (
|
||||||
|
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||||
|
<video
|
||||||
|
className='media-gallery__item-gifv-thumbnail'
|
||||||
|
aria-label={attachment.get('description')}
|
||||||
|
title={attachment.get('description')}
|
||||||
|
role='application'
|
||||||
|
src={attachment.get('url')}
|
||||||
|
onMouseEnter={this.handleMouseEnter}
|
||||||
|
onMouseLeave={this.handleMouseLeave}
|
||||||
|
autoPlay={autoPlay}
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className='media-gallery__gifv__label'>GIF</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account-gallery__item'>
|
<div className='account-gallery__item' style={{ width, height }}>
|
||||||
<Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}>
|
<a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick}>
|
||||||
{icon}
|
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
|
||||||
{label}
|
{visible && thumbnail}
|
||||||
</Permalink>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,24 +2,25 @@ import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { fetchAccount } from '../../actions/accounts';
|
import { fetchAccount } from 'mastodon/actions/accounts';
|
||||||
import { expandAccountMediaTimeline } from '../../actions/timelines';
|
import { expandAccountMediaTimeline } from '../../actions/timelines';
|
||||||
import LoadingIndicator from '../../components/loading_indicator';
|
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
import ColumnBackButton from '../../components/column_back_button';
|
import ColumnBackButton from 'mastodon/components/column_back_button';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { getAccountGallery } from '../../selectors';
|
import { getAccountGallery } from 'mastodon/selectors';
|
||||||
import MediaItem from './components/media_item';
|
import MediaItem from './components/media_item';
|
||||||
import HeaderContainer from '../account_timeline/containers/header_container';
|
import HeaderContainer from '../account_timeline/containers/header_container';
|
||||||
import { ScrollContainer } from 'react-router-scroll-4';
|
import { ScrollContainer } from 'react-router-scroll-4';
|
||||||
import LoadMore from '../../components/load_more';
|
import LoadMore from 'mastodon/components/load_more';
|
||||||
import MissingIndicator from 'mastodon/components/missing_indicator';
|
import MissingIndicator from 'mastodon/components/missing_indicator';
|
||||||
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
||||||
medias: getAccountGallery(state, props.params.accountId),
|
attachments: getAccountGallery(state, props.params.accountId),
|
||||||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
|
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
|
||||||
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
|
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
|
||||||
});
|
});
|
||||||
|
|
||||||
class LoadMoreMedia extends ImmutablePureComponent {
|
class LoadMoreMedia extends ImmutablePureComponent {
|
||||||
|
@ -51,12 +52,16 @@ class AccountGallery extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
params: PropTypes.object.isRequired,
|
params: PropTypes.object.isRequired,
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
medias: ImmutablePropTypes.list.isRequired,
|
attachments: ImmutablePropTypes.list.isRequired,
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
isAccount: PropTypes.bool,
|
isAccount: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
width: 323,
|
||||||
|
};
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
|
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
|
||||||
|
@ -71,11 +76,11 @@ class AccountGallery extends ImmutablePureComponent {
|
||||||
|
|
||||||
handleScrollToBottom = () => {
|
handleScrollToBottom = () => {
|
||||||
if (this.props.hasMore) {
|
if (this.props.hasMore) {
|
||||||
this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
|
this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleScroll = (e) => {
|
handleScroll = e => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||||
const offset = scrollHeight - scrollTop - clientHeight;
|
const offset = scrollHeight - scrollTop - clientHeight;
|
||||||
|
|
||||||
|
@ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent {
|
||||||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
|
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleLoadOlder = (e) => {
|
handleLoadOlder = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.handleScrollToBottom();
|
this.handleScrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOpenMedia = attachment => {
|
||||||
|
if (attachment.get('type') === 'video') {
|
||||||
|
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
|
||||||
|
} else {
|
||||||
|
const media = attachment.getIn(['status', 'media_attachments']);
|
||||||
|
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
|
||||||
|
|
||||||
|
this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRef = c => {
|
||||||
|
if (c) {
|
||||||
|
this.setState({ width: c.offsetWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { medias, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
|
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
|
||||||
|
const { width } = this.state;
|
||||||
|
|
||||||
if (!isAccount) {
|
if (!isAccount) {
|
||||||
return (
|
return (
|
||||||
|
@ -104,9 +127,7 @@ class AccountGallery extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let loadOlder = null;
|
if (!attachments && isLoading) {
|
||||||
|
|
||||||
if (!medias && isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
|
@ -114,7 +135,9 @@ class AccountGallery extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasMore && !(isLoading && medias.size === 0)) {
|
let loadOlder = null;
|
||||||
|
|
||||||
|
if (hasMore && !(isLoading && attachments.size === 0)) {
|
||||||
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
|
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -126,23 +149,17 @@ class AccountGallery extends ImmutablePureComponent {
|
||||||
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
|
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
|
||||||
<HeaderContainer accountId={this.props.params.accountId} />
|
<HeaderContainer accountId={this.props.params.accountId} />
|
||||||
|
|
||||||
<div role='feed' className='account-gallery__container'>
|
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
|
||||||
{medias.map((media, index) => media === null ? (
|
{attachments.map((attachment, index) => attachment === null ? (
|
||||||
<LoadMoreMedia
|
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
|
||||||
key={'more:' + medias.getIn(index + 1, 'id')}
|
|
||||||
maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
|
|
||||||
onLoadMore={this.handleLoadMore}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<MediaItem
|
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
|
||||||
key={media.get('id')}
|
|
||||||
media={media}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{loadOlder}
|
{loadOlder}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && medias.size === 0 && (
|
{isLoading && attachments.size === 0 && (
|
||||||
<div className='scrollable__append'>
|
<div className='scrollable__append'>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,7 +10,6 @@ import UploadButtonContainer from '../containers/upload_button_container';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
||||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||||
import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
|
||||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||||
import PollFormContainer from '../containers/poll_form_container';
|
import PollFormContainer from '../containers/poll_form_container';
|
||||||
import UploadFormContainer from '../containers/upload_form_container';
|
import UploadFormContainer from '../containers/upload_form_container';
|
||||||
|
@ -215,7 +214,6 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
<UploadButtonContainer />
|
<UploadButtonContainer />
|
||||||
<PollButtonContainer />
|
<PollButtonContainer />
|
||||||
<PrivacyDropdownContainer />
|
<PrivacyDropdownContainer />
|
||||||
<SensitiveButtonContainer />
|
|
||||||
<SpoilerButtonContainer />
|
<SpoilerButtonContainer />
|
||||||
</div>
|
</div>
|
||||||
<div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
|
<div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import UploadProgressContainer from '../containers/upload_progress_container';
|
import UploadProgressContainer from '../containers/upload_progress_container';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import UploadContainer from '../containers/upload_container';
|
import UploadContainer from '../containers/upload_container';
|
||||||
|
import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
||||||
|
|
||||||
export default class UploadForm extends ImmutablePureComponent {
|
export default class UploadForm extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
@ -22,6 +23,8 @@ export default class UploadForm extends ImmutablePureComponent {
|
||||||
<UploadContainer id={id} key={id} />
|
<UploadContainer id={id} key={id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!mediaIds.isEmpty() && <SensitiveButtonContainer />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,9 @@ import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import IconButton from '../../../components/icon_button';
|
import { changeComposeSensitivity } from 'mastodon/actions/compose';
|
||||||
import { changeComposeSensitivity } from '../../../actions/compose';
|
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||||
import Motion from '../../ui/util/optional_motion';
|
import Icon from 'mastodon/components/icon';
|
||||||
import spring from 'react-motion/lib/spring';
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
|
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
|
||||||
|
@ -14,7 +12,6 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
visible: state.getIn(['compose', 'media_attachments']).size > 0,
|
|
||||||
active: state.getIn(['compose', 'sensitive']),
|
active: state.getIn(['compose', 'sensitive']),
|
||||||
disabled: state.getIn(['compose', 'spoiler']),
|
disabled: state.getIn(['compose', 'spoiler']),
|
||||||
});
|
});
|
||||||
|
@ -30,7 +27,6 @@ const mapDispatchToProps = dispatch => ({
|
||||||
class SensitiveButton extends React.PureComponent {
|
class SensitiveButton extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
visible: PropTypes.bool,
|
|
||||||
active: PropTypes.bool,
|
active: PropTypes.bool,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
onClick: PropTypes.func.isRequired,
|
onClick: PropTypes.func.isRequired,
|
||||||
|
@ -38,32 +34,14 @@ class SensitiveButton extends React.PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { visible, active, disabled, onClick, intl } = this.props;
|
const { active, disabled, onClick, intl } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
|
<div className='compose-form__sensitive-button'>
|
||||||
{({ scale }) => {
|
<button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
|
||||||
const icon = active ? 'eye-slash' : 'eye';
|
<Icon id='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
|
||||||
const className = classNames('compose-form__sensitive-button', {
|
</button>
|
||||||
'compose-form__sensitive-button--visible': visible,
|
</div>
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div className={className} style={{ transform: `scale(${scale})` }}>
|
|
||||||
<IconButton
|
|
||||||
className='compose-form__sensitive-button__icon'
|
|
||||||
title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
|
|
||||||
icon={icon}
|
|
||||||
onClick={onClick}
|
|
||||||
size={18}
|
|
||||||
active={active}
|
|
||||||
disabled={disabled}
|
|
||||||
style={{ lineHeight: null, height: null }}
|
|
||||||
inverted
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Motion>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,18 +20,24 @@ export default class ConversationsList extends ImmutablePureComponent {
|
||||||
|
|
||||||
handleMoveUp = id => {
|
handleMoveUp = id => {
|
||||||
const elementIndex = this.getCurrentIndex(id) - 1;
|
const elementIndex = this.getCurrentIndex(id) - 1;
|
||||||
this._selectChild(elementIndex);
|
this._selectChild(elementIndex, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMoveDown = id => {
|
handleMoveDown = id => {
|
||||||
const elementIndex = this.getCurrentIndex(id) + 1;
|
const elementIndex = this.getCurrentIndex(id) + 1;
|
||||||
this._selectChild(elementIndex);
|
this._selectChild(elementIndex, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_selectChild (index) {
|
_selectChild (index, align_top) {
|
||||||
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
const container = this.node.node;
|
||||||
|
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
|
if (align_top && container.scrollTop > element.offsetTop) {
|
||||||
|
element.scrollIntoView(true);
|
||||||
|
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||||
|
element.scrollIntoView(false);
|
||||||
|
}
|
||||||
element.focus();
|
element.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -113,18 +113,24 @@ class Notifications extends React.PureComponent {
|
||||||
|
|
||||||
handleMoveUp = id => {
|
handleMoveUp = id => {
|
||||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
||||||
this._selectChild(elementIndex);
|
this._selectChild(elementIndex, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMoveDown = id => {
|
handleMoveDown = id => {
|
||||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
||||||
this._selectChild(elementIndex);
|
this._selectChild(elementIndex, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_selectChild (index) {
|
_selectChild (index, align_top) {
|
||||||
const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
const container = this.column.node;
|
||||||
|
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
|
if (align_top && container.scrollTop > element.offsetTop) {
|
||||||
|
element.scrollIntoView(true);
|
||||||
|
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||||
|
element.scrollIntoView(false);
|
||||||
|
}
|
||||||
element.focus();
|
element.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent {
|
||||||
{Component => (
|
{Component => (
|
||||||
<Component
|
<Component
|
||||||
preview={video.get('preview_url')}
|
preview={video.get('preview_url')}
|
||||||
|
blurhash={video.get('blurhash')}
|
||||||
src={video.get('url')}
|
src={video.get('url')}
|
||||||
alt={video.get('description')}
|
alt={video.get('description')}
|
||||||
width={239}
|
width={239}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar';
|
||||||
import DisplayName from '../../../components/display_name';
|
import DisplayName from '../../../components/display_name';
|
||||||
import StatusContent from '../../../components/status_content';
|
import StatusContent from '../../../components/status_content';
|
||||||
import MediaGallery from '../../../components/media_gallery';
|
import MediaGallery from '../../../components/media_gallery';
|
||||||
import AttachmentList from '../../../components/attachment_list';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { FormattedDate, FormattedNumber } from 'react-intl';
|
import { FormattedDate, FormattedNumber } from 'react-intl';
|
||||||
import Card from './card';
|
import Card from './card';
|
||||||
|
@ -109,14 +108,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||||
if (status.get('poll')) {
|
if (status.get('poll')) {
|
||||||
media = <PollContainer pollId={status.get('poll')} />;
|
media = <PollContainer pollId={status.get('poll')} />;
|
||||||
} else if (status.get('media_attachments').size > 0) {
|
} else if (status.get('media_attachments').size > 0) {
|
||||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
media = <AttachmentList media={status.get('media_attachments')} />;
|
|
||||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
|
||||||
const video = status.getIn(['media_attachments', 0]);
|
const video = status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
<Video
|
<Video
|
||||||
preview={video.get('preview_url')}
|
preview={video.get('preview_url')}
|
||||||
|
blurhash={video.get('blurhash')}
|
||||||
src={video.get('url')}
|
src={video.get('url')}
|
||||||
alt={video.get('description')}
|
alt={video.get('description')}
|
||||||
width={300}
|
width={300}
|
||||||
|
|
|
@ -316,15 +316,15 @@ class Status extends ImmutablePureComponent {
|
||||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||||
|
|
||||||
if (id === status.get('id')) {
|
if (id === status.get('id')) {
|
||||||
this._selectChild(ancestorsIds.size - 1);
|
this._selectChild(ancestorsIds.size - 1, true);
|
||||||
} else {
|
} else {
|
||||||
let index = ancestorsIds.indexOf(id);
|
let index = ancestorsIds.indexOf(id);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
index = descendantsIds.indexOf(id);
|
index = descendantsIds.indexOf(id);
|
||||||
this._selectChild(ancestorsIds.size + index);
|
this._selectChild(ancestorsIds.size + index, true);
|
||||||
} else {
|
} else {
|
||||||
this._selectChild(index - 1);
|
this._selectChild(index - 1, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -333,23 +333,29 @@ class Status extends ImmutablePureComponent {
|
||||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||||
|
|
||||||
if (id === status.get('id')) {
|
if (id === status.get('id')) {
|
||||||
this._selectChild(ancestorsIds.size + 1);
|
this._selectChild(ancestorsIds.size + 1, false);
|
||||||
} else {
|
} else {
|
||||||
let index = ancestorsIds.indexOf(id);
|
let index = ancestorsIds.indexOf(id);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
index = descendantsIds.indexOf(id);
|
index = descendantsIds.indexOf(id);
|
||||||
this._selectChild(ancestorsIds.size + index + 2);
|
this._selectChild(ancestorsIds.size + index + 2, false);
|
||||||
} else {
|
} else {
|
||||||
this._selectChild(index + 1);
|
this._selectChild(index + 1, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_selectChild (index) {
|
_selectChild (index, align_top) {
|
||||||
const element = this.node.querySelectorAll('.focusable')[index];
|
const container = this.node;
|
||||||
|
const element = container.querySelectorAll('.focusable')[index];
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
|
if (align_top && container.scrollTop > element.offsetTop) {
|
||||||
|
element.scrollIntoView(true);
|
||||||
|
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||||
|
element.scrollIntoView(false);
|
||||||
|
}
|
||||||
element.focus();
|
element.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,11 @@ import React from 'react';
|
||||||
import ReactSwipeableViews from 'react-swipeable-views';
|
import ReactSwipeableViews from 'react-swipeable-views';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Video from '../../video';
|
import Video from 'mastodon/features/video';
|
||||||
import ExtendedVideoPlayer from '../../../components/extended_video_player';
|
import ExtendedVideoPlayer from 'mastodon/components/extended_video_player';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import IconButton from '../../../components/icon_button';
|
import IconButton from 'mastodon/components/icon_button';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import ImageLoader from './image_loader';
|
import ImageLoader from './image_loader';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
|
@ -24,6 +24,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.list.isRequired,
|
media: ImmutablePropTypes.list.isRequired,
|
||||||
|
status: ImmutablePropTypes.map,
|
||||||
index: PropTypes.number.isRequired,
|
index: PropTypes.number.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
|
@ -72,9 +73,12 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
window.addEventListener('keydown', this.handleKeyDown, false);
|
window.addEventListener('keydown', this.handleKeyDown, false);
|
||||||
|
|
||||||
if (this.context.router) {
|
if (this.context.router) {
|
||||||
const history = this.context.router.history;
|
const history = this.context.router.history;
|
||||||
|
|
||||||
history.push(history.location.pathname, previewState);
|
history.push(history.location.pathname, previewState);
|
||||||
|
|
||||||
this.unlistenHistory = history.listen(() => {
|
this.unlistenHistory = history.listen(() => {
|
||||||
this.props.onClose();
|
this.props.onClose();
|
||||||
});
|
});
|
||||||
|
@ -83,6 +87,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
window.removeEventListener('keydown', this.handleKeyDown);
|
window.removeEventListener('keydown', this.handleKeyDown);
|
||||||
|
|
||||||
if (this.context.router) {
|
if (this.context.router) {
|
||||||
this.unlistenHistory();
|
this.unlistenHistory();
|
||||||
|
|
||||||
|
@ -102,8 +107,15 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleStatusClick = e => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, intl, onClose } = this.props;
|
const { media, status, intl, onClose } = this.props;
|
||||||
const { navigationHidden } = this.state;
|
const { navigationHidden } = this.state;
|
||||||
|
|
||||||
const index = this.getIndex();
|
const index = this.getIndex();
|
||||||
|
@ -144,6 +156,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<Video
|
<Video
|
||||||
preview={image.get('preview_url')}
|
preview={image.get('preview_url')}
|
||||||
|
blurhash={image.get('blurhash')}
|
||||||
src={image.get('url')}
|
src={image.get('url')}
|
||||||
width={image.get('width')}
|
width={image.get('width')}
|
||||||
height={image.get('height')}
|
height={image.get('height')}
|
||||||
|
@ -206,10 +219,19 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
{content}
|
{content}
|
||||||
</ReactSwipeableViews>
|
</ReactSwipeableViews>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={navigationClassName}>
|
<div className={navigationClassName}>
|
||||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
|
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
|
||||||
|
|
||||||
{leftNav}
|
{leftNav}
|
||||||
{rightNav}
|
{rightNav}
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
|
||||||
|
<a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ul className='media-modal__pagination'>
|
<ul className='media-modal__pagination'>
|
||||||
{pagination}
|
{pagination}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -1,28 +1,69 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Video from '../../video';
|
import Video from 'mastodon/features/video';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
export const previewState = 'previewVideoModal';
|
||||||
|
|
||||||
export default class VideoModal extends ImmutablePureComponent {
|
export default class VideoModal extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
|
status: ImmutablePropTypes.map,
|
||||||
time: PropTypes.number,
|
time: PropTypes.number,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
if (this.context.router) {
|
||||||
|
const history = this.context.router.history;
|
||||||
|
|
||||||
|
history.push(history.location.pathname, previewState);
|
||||||
|
|
||||||
|
this.unlistenHistory = history.listen(() => {
|
||||||
|
this.props.onClose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
if (this.context.router) {
|
||||||
|
this.unlistenHistory();
|
||||||
|
|
||||||
|
if (this.context.router.history.location.state === previewState) {
|
||||||
|
this.context.router.history.goBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStatusClick = e => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, time, onClose } = this.props;
|
const { media, status, time, onClose } = this.props;
|
||||||
|
|
||||||
|
const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal video-modal'>
|
<div className='modal-root__modal video-modal'>
|
||||||
<div>
|
<div>
|
||||||
<Video
|
<Video
|
||||||
preview={media.get('preview_url')}
|
preview={media.get('preview_url')}
|
||||||
|
blurhash={media.get('blurhash')}
|
||||||
src={media.get('url')}
|
src={media.get('url')}
|
||||||
startTime={time}
|
startTime={time}
|
||||||
onCloseVideo={onClose}
|
onCloseVideo={onClose}
|
||||||
|
link={link}
|
||||||
detailed
|
detailed
|
||||||
alt={media.get('description')}
|
alt={media.get('description')}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -47,7 +47,8 @@ import {
|
||||||
Lists,
|
Lists,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { me } from '../../initial_state';
|
import { me } from '../../initial_state';
|
||||||
import { previewState } from './components/media_modal';
|
import { previewState as previewMediaState } from './components/media_modal';
|
||||||
|
import { previewState as previewVideoState } from './components/video_modal';
|
||||||
|
|
||||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||||
// Without this it ends up in ~8 very commonly used bundles.
|
// Without this it ends up in ~8 very commonly used bundles.
|
||||||
|
@ -121,7 +122,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldUpdateScroll (_, { location }) {
|
shouldUpdateScroll (_, { location }) {
|
||||||
return location.state !== previewState;
|
return location.state !== previewMediaState && location.state !== previewVideoState;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleResize = debounce(() => {
|
handleResize = debounce(() => {
|
||||||
|
@ -367,11 +368,16 @@ class UI extends React.PureComponent {
|
||||||
handleHotkeyFocusColumn = e => {
|
handleHotkeyFocusColumn = e => {
|
||||||
const index = (e.key * 1) + 1; // First child is drawer, skip that
|
const index = (e.key * 1) + 1; // First child is drawer, skip that
|
||||||
const column = this.node.querySelector(`.column:nth-child(${index})`);
|
const column = this.node.querySelector(`.column:nth-child(${index})`);
|
||||||
|
if (!column) return;
|
||||||
|
const container = column.querySelector('.scrollable');
|
||||||
|
|
||||||
if (column) {
|
if (container) {
|
||||||
const status = column.querySelector('.focusable');
|
const status = container.querySelector('.focusable');
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
|
if (container.scrollTop > status.offsetTop) {
|
||||||
|
status.scrollIntoView(true);
|
||||||
|
}
|
||||||
status.focus();
|
status.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import classNames from 'classnames';
|
||||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||||
import { displayMedia } from '../../initial_state';
|
import { displayMedia } from '../../initial_state';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
|
import { decode } from 'blurhash';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||||
|
@ -102,6 +103,8 @@ class Video extends React.PureComponent {
|
||||||
inline: PropTypes.bool,
|
inline: PropTypes.bool,
|
||||||
cacheWidth: PropTypes.func,
|
cacheWidth: PropTypes.func,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
|
blurhash: PropTypes.string,
|
||||||
|
link: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -139,6 +142,7 @@ class Video extends React.PureComponent {
|
||||||
|
|
||||||
setVideoRef = c => {
|
setVideoRef = c => {
|
||||||
this.video = c;
|
this.video = c;
|
||||||
|
|
||||||
if (this.video) {
|
if (this.video) {
|
||||||
this.setState({ volume: this.video.volume, muted: this.video.muted });
|
this.setState({ volume: this.video.volume, muted: this.video.muted });
|
||||||
}
|
}
|
||||||
|
@ -152,6 +156,10 @@ class Video extends React.PureComponent {
|
||||||
this.volume = c;
|
this.volume = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCanvasRef = c => {
|
||||||
|
this.canvas = c;
|
||||||
|
}
|
||||||
|
|
||||||
handleClickRoot = e => e.stopPropagation();
|
handleClickRoot = e => e.stopPropagation();
|
||||||
|
|
||||||
handlePlay = () => {
|
handlePlay = () => {
|
||||||
|
@ -170,7 +178,6 @@ class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleVolumeMouseDown = e => {
|
handleVolumeMouseDown = e => {
|
||||||
|
|
||||||
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||||
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||||
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
||||||
|
@ -190,7 +197,6 @@ class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseVolSlide = throttle(e => {
|
handleMouseVolSlide = throttle(e => {
|
||||||
|
|
||||||
const rect = this.volume.getBoundingClientRect();
|
const rect = this.volume.getBoundingClientRect();
|
||||||
const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
|
const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
|
||||||
|
|
||||||
|
@ -261,6 +267,10 @@ class Video extends React.PureComponent {
|
||||||
document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
|
document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
|
||||||
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
|
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
|
||||||
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
||||||
|
|
||||||
|
if (this.props.blurhash) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
|
@ -270,6 +280,24 @@ class Video extends React.PureComponent {
|
||||||
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidUpdate (prevProps) {
|
||||||
|
if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
|
||||||
|
this._decode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_decode () {
|
||||||
|
const hash = this.props.blurhash;
|
||||||
|
const pixels = decode(hash, 32, 32);
|
||||||
|
|
||||||
|
if (pixels) {
|
||||||
|
const ctx = this.canvas.getContext('2d');
|
||||||
|
const imageData = new ImageData(pixels, 32, 32);
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleFullscreenChange = () => {
|
handleFullscreenChange = () => {
|
||||||
this.setState({ fullscreen: isFullscreen() });
|
this.setState({ fullscreen: isFullscreen() });
|
||||||
}
|
}
|
||||||
|
@ -314,6 +342,7 @@ class Video extends React.PureComponent {
|
||||||
|
|
||||||
handleOpenVideo = () => {
|
handleOpenVideo = () => {
|
||||||
const { src, preview, width, height, alt } = this.props;
|
const { src, preview, width, height, alt } = this.props;
|
||||||
|
|
||||||
const media = fromJS({
|
const media = fromJS({
|
||||||
type: 'video',
|
type: 'video',
|
||||||
url: src,
|
url: src,
|
||||||
|
@ -333,7 +362,7 @@ class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive } = this.props;
|
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link } = this.props;
|
||||||
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
||||||
const progress = (currentTime / duration) * 100;
|
const progress = (currentTime / duration) * 100;
|
||||||
|
|
||||||
|
@ -351,6 +380,7 @@ class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
let preload;
|
let preload;
|
||||||
|
|
||||||
if (startTime || fullscreen || dragging) {
|
if (startTime || fullscreen || dragging) {
|
||||||
preload = 'auto';
|
preload = 'auto';
|
||||||
} else if (detailed) {
|
} else if (detailed) {
|
||||||
|
@ -360,6 +390,7 @@ class Video extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
let warning;
|
let warning;
|
||||||
|
|
||||||
if (sensitive) {
|
if (sensitive) {
|
||||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||||
} else {
|
} else {
|
||||||
|
@ -377,7 +408,9 @@ class Video extends React.PureComponent {
|
||||||
onClick={this.handleClickRoot}
|
onClick={this.handleClickRoot}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<video
|
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
|
||||||
|
|
||||||
|
{revealed && <video
|
||||||
ref={this.setVideoRef}
|
ref={this.setVideoRef}
|
||||||
src={src}
|
src={src}
|
||||||
poster={preview}
|
poster={preview}
|
||||||
|
@ -397,12 +430,13 @@ class Video extends React.PureComponent {
|
||||||
onLoadedData={this.handleLoadedData}
|
onLoadedData={this.handleLoadedData}
|
||||||
onProgress={this.handleProgress}
|
onProgress={this.handleProgress}
|
||||||
onVolumeChange={this.handleVolumeChange}
|
onVolumeChange={this.handleVolumeChange}
|
||||||
/>
|
/>}
|
||||||
|
|
||||||
<button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
|
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
|
||||||
<span className='video-player__spoiler__title'>{warning}</span>
|
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||||
<span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
||||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
||||||
|
@ -420,6 +454,7 @@ class Video extends React.PureComponent {
|
||||||
<div className='video-player__buttons left'>
|
<div className='video-player__buttons left'>
|
||||||
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
|
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
|
||||||
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
|
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
|
||||||
|
|
||||||
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
||||||
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
|
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
|
||||||
<span
|
<span
|
||||||
|
@ -429,17 +464,19 @@ class Video extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(detailed || fullscreen) &&
|
{(detailed || fullscreen) && (
|
||||||
<span>
|
<span>
|
||||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||||
<span className='video-player__time-sep'>/</span>
|
<span className='video-player__time-sep'>/</span>
|
||||||
<span className='video-player__time-total'>{formatTime(duration)}</span>
|
<span className='video-player__time-total'>{formatTime(duration)}</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
)}
|
||||||
|
|
||||||
|
{link && <span className='video-player__link'>{link}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='video-player__buttons right'>
|
<div className='video-player__buttons right'>
|
||||||
{!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye' fixedWidth /></button>}
|
{!onCloseVideo && <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>}
|
{(!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>}
|
{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>
|
<button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
|
||||||
|
|
|
@ -77,6 +77,7 @@
|
||||||
"compose_form.poll.remove_option": "Odstranit tuto volbu",
|
"compose_form.poll.remove_option": "Odstranit tuto volbu",
|
||||||
"compose_form.publish": "Tootnout",
|
"compose_form.publish": "Tootnout",
|
||||||
"compose_form.publish_loud": "{publish}!",
|
"compose_form.publish_loud": "{publish}!",
|
||||||
|
"compose_form.sensitive.hide": "Označit média jako citlivá",
|
||||||
"compose_form.sensitive.marked": "Média jsou označena jako citlivá",
|
"compose_form.sensitive.marked": "Média jsou označena jako citlivá",
|
||||||
"compose_form.sensitive.unmarked": "Média nejsou označena jako citlivá",
|
"compose_form.sensitive.unmarked": "Média nejsou označena jako citlivá",
|
||||||
"compose_form.spoiler.marked": "Text je skrytý za varováním",
|
"compose_form.spoiler.marked": "Text je skrytý za varováním",
|
||||||
|
@ -209,6 +210,7 @@
|
||||||
"lightbox.close": "Zavřít",
|
"lightbox.close": "Zavřít",
|
||||||
"lightbox.next": "Další",
|
"lightbox.next": "Další",
|
||||||
"lightbox.previous": "Předchozí",
|
"lightbox.previous": "Předchozí",
|
||||||
|
"lightbox.view_context": "Zobrazit kontext",
|
||||||
"lists.account.add": "Přidat do seznamu",
|
"lists.account.add": "Přidat do seznamu",
|
||||||
"lists.account.remove": "Odebrat ze seznamu",
|
"lists.account.remove": "Odebrat ze seznamu",
|
||||||
"lists.delete": "Smazat seznam",
|
"lists.delete": "Smazat seznam",
|
||||||
|
|
|
@ -243,7 +243,7 @@
|
||||||
"navigation_bar.pins": "Ամրացված թթեր",
|
"navigation_bar.pins": "Ամրացված թթեր",
|
||||||
"navigation_bar.preferences": "Նախապատվություններ",
|
"navigation_bar.preferences": "Նախապատվություններ",
|
||||||
"navigation_bar.public_timeline": "Դաշնային հոսք",
|
"navigation_bar.public_timeline": "Դաշնային հոսք",
|
||||||
"navigation_bar.security": "Security",
|
"navigation_bar.security": "Անվտանգություն",
|
||||||
"notification.favourite": "{name} հավանեց թութդ",
|
"notification.favourite": "{name} հավանեց թութդ",
|
||||||
"notification.follow": "{name} սկսեց հետեւել քեզ",
|
"notification.follow": "{name} սկսեց հետեւել քեզ",
|
||||||
"notification.mention": "{name} նշեց քեզ",
|
"notification.mention": "{name} նշեց քեզ",
|
||||||
|
@ -309,7 +309,7 @@
|
||||||
"search_results.accounts": "People",
|
"search_results.accounts": "People",
|
||||||
"search_results.hashtags": "Hashtags",
|
"search_results.hashtags": "Hashtags",
|
||||||
"search_results.statuses": "Toots",
|
"search_results.statuses": "Toots",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {արդյունք} other {արդյունք}}",
|
||||||
"status.admin_account": "Open moderation interface for @{name}",
|
"status.admin_account": "Open moderation interface for @{name}",
|
||||||
"status.admin_status": "Open this status in the moderation interface",
|
"status.admin_status": "Open this status in the moderation interface",
|
||||||
"status.block": "Արգելափակել @{name}֊ին",
|
"status.block": "Արգելափակել @{name}֊ին",
|
||||||
|
|
|
@ -50,6 +50,7 @@ $content-width: 840px;
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 200ms linear;
|
transition: all 200ms linear;
|
||||||
|
transition-property: color, background-color;
|
||||||
border-radius: 4px 0 0 4px;
|
border-radius: 4px 0 0 4px;
|
||||||
|
|
||||||
i.fa {
|
i.fa {
|
||||||
|
@ -60,6 +61,7 @@ $content-width: 840px;
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
background-color: darken($ui-base-color, 5%);
|
background-color: darken($ui-base-color, 5%);
|
||||||
transition: all 100ms linear;
|
transition: all 100ms linear;
|
||||||
|
transition-property: color, background-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
|
|
|
@ -264,6 +264,16 @@
|
||||||
.compose-form {
|
.compose-form {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|
||||||
|
&__sensitive-button {
|
||||||
|
padding: 10px;
|
||||||
|
padding-top: 0;
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.compose-form__warning {
|
.compose-form__warning {
|
||||||
color: $inverted-text-color;
|
color: $inverted-text-color;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
@ -1962,6 +1972,7 @@ a.account__display-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-bottom: 2px solid lighten($ui-base-color, 8%);
|
border-bottom: 2px solid lighten($ui-base-color, 8%);
|
||||||
transition: all 50ms linear;
|
transition: all 50ms linear;
|
||||||
|
transition-property: border-bottom, background, color;
|
||||||
|
|
||||||
.fa {
|
.fa {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
@ -2127,7 +2138,7 @@ a.account__display-name {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
background-color: $ui-base-color;
|
background-color: $ui-base-color;
|
||||||
transition: all 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
|
.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
|
||||||
|
@ -2180,7 +2191,6 @@ a.account__display-name {
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle-thumb {
|
.react-toggle-thumb {
|
||||||
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 1px;
|
top: 1px;
|
||||||
left: 1px;
|
left: 1px;
|
||||||
|
@ -2191,6 +2201,7 @@ a.account__display-name {
|
||||||
background-color: darken($simple-background-color, 2%);
|
background-color: darken($simple-background-color, 2%);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
|
transition-property: border-color, left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-toggle--checked .react-toggle-thumb {
|
.react-toggle--checked .react-toggle-thumb {
|
||||||
|
@ -2412,7 +2423,7 @@ a.account__display-name {
|
||||||
|
|
||||||
& > div {
|
& > div {
|
||||||
background: rgba($base-shadow-color, 0.6);
|
background: rgba($base-shadow-color, 0.6);
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
padding: 12px 9px;
|
padding: 12px 9px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -2423,19 +2434,18 @@ a.account__display-name {
|
||||||
button,
|
button,
|
||||||
a {
|
a {
|
||||||
display: inline;
|
display: inline;
|
||||||
color: $primary-text-color;
|
color: $secondary-text-color;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0 5px;
|
padding: 0 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus {
|
||||||
opacity: 1;
|
color: $primary-text-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2932,15 +2942,49 @@ a.status-card.compact:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.spoiler-button {
|
.spoiler-button {
|
||||||
display: none;
|
top: 0;
|
||||||
left: 4px;
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
|
|
||||||
top: 4px;
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
&.spoiler-button--visible {
|
&--minified {
|
||||||
display: block;
|
display: block;
|
||||||
|
left: 4px;
|
||||||
|
top: 4px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__overlay {
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba($base-overlay-background, 0.5);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: $primary-text-color;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
.spoiler-button__overlay__label {
|
||||||
|
background: rgba($base-overlay-background, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3509,6 +3553,7 @@ a.status-card.compact:hover {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 100ms linear;
|
transition: all 100ms linear;
|
||||||
|
transition-property: transform, opacity;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
@ -3728,6 +3773,31 @@ a.status-card.compact:hover {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-modal__meta {
|
||||||
|
text-align: center;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 20px;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&--shifted {
|
||||||
|
bottom: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $ui-secondary-color;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.media-modal__page-dot {
|
.media-modal__page-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
@ -4200,6 +4270,7 @@ a.status-card.compact:hover {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
transition: opacity 0.1s ease;
|
transition: opacity 0.1s ease;
|
||||||
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__gifv {
|
.media-gallery__gifv {
|
||||||
|
@ -4313,6 +4384,8 @@ a.status-card.compact:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: $secondary-text-color;
|
color: $secondary-text-color;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
&,
|
&,
|
||||||
img {
|
img {
|
||||||
|
@ -4325,6 +4398,21 @@ a.status-card.compact:hover {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-gallery__preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 0;
|
||||||
|
background: $base-overlay-background;
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.media-gallery__gifv {
|
.media-gallery__gifv {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -4620,6 +4708,23 @@ a.status-card.compact:hover {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__link {
|
||||||
|
padding: 2px 10px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $white;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__seek {
|
&__seek {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
@ -4712,62 +4817,18 @@ a.status-card.compact:hover {
|
||||||
|
|
||||||
.account-gallery__container {
|
.account-gallery__container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 2px;
|
padding: 4px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-gallery__item {
|
.account-gallery__item {
|
||||||
flex-grow: 1;
|
border: none;
|
||||||
width: 50%;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
&::before {
|
overflow: hidden;
|
||||||
content: "";
|
margin: 2px;
|
||||||
display: block;
|
|
||||||
padding-top: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: block;
|
|
||||||
width: calc(100% - 4px);
|
|
||||||
height: calc(100% - 4px);
|
|
||||||
margin: 2px;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
background-color: $base-overlay-background;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
position: absolute;
|
|
||||||
color: $darker-text-color;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:active,
|
|
||||||
&:focus {
|
|
||||||
outline: 0;
|
|
||||||
color: $secondary-text-color;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba($base-overlay-background, 0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icons {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification__filter-bar,
|
.notification__filter-bar,
|
||||||
|
|
|
@ -533,6 +533,17 @@ code {
|
||||||
color: $error-value-color;
|
color: $error-value-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: inline-block;
|
||||||
|
color: $darker-text-color;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-text-color;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
&__img {
|
&__img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 167px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
|
|
|
@ -194,7 +194,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
next if attachment['url'].blank?
|
next if attachment['url'].blank?
|
||||||
|
|
||||||
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
||||||
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
|
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
|
||||||
media_attachments << media_attachment
|
media_attachments << media_attachment
|
||||||
|
|
||||||
next if unsupported_media_type?(attachment['mediaType']) || skip_download?
|
next if unsupported_media_type?(attachment['mediaType']) || skip_download?
|
||||||
|
@ -369,6 +369,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||||
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
|
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def supported_blurhash?(blurhash)
|
||||||
|
components = blurhash.blank? ? nil : Blurhash.components(blurhash)
|
||||||
|
components.present? && components.none? { |comp| comp > 5 }
|
||||||
|
end
|
||||||
|
|
||||||
def skip_download?
|
def skip_download?
|
||||||
return @skip_download if defined?(@skip_download)
|
return @skip_download if defined?(@skip_download)
|
||||||
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
|
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
|
||||||
|
|
|
@ -19,6 +19,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||||
conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
|
conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
|
||||||
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
|
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
|
||||||
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
||||||
|
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def self.default_key_transform
|
def self.default_key_transform
|
||||||
|
|
|
@ -6,6 +6,7 @@ module LdapAuthenticable
|
||||||
def ldap_setup(_attributes)
|
def ldap_setup(_attributes)
|
||||||
self.confirmed_at = Time.now.utc
|
self.confirmed_at = Time.now.utc
|
||||||
self.admin = false
|
self.admin = false
|
||||||
|
self.external = true
|
||||||
|
|
||||||
save!
|
save!
|
||||||
end
|
end
|
||||||
|
|
|
@ -66,6 +66,7 @@ module Omniauthable
|
||||||
email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
|
email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
|
||||||
password: Devise.friendly_token[0, 20],
|
password: Devise.friendly_token[0, 20],
|
||||||
agreement: true,
|
agreement: true,
|
||||||
|
external: true,
|
||||||
account_attributes: {
|
account_attributes: {
|
||||||
username: ensure_unique_username(auth.uid),
|
username: ensure_unique_username(auth.uid),
|
||||||
display_name: display_name,
|
display_name: display_name,
|
||||||
|
|
|
@ -34,6 +34,7 @@ module PamAuthenticable
|
||||||
self.confirmed_at = Time.now.utc
|
self.confirmed_at = Time.now.utc
|
||||||
self.admin = false
|
self.admin = false
|
||||||
self.account = account
|
self.account = account
|
||||||
|
self.external = true
|
||||||
|
|
||||||
account.destroy! unless save
|
account.destroy! unless save
|
||||||
end
|
end
|
||||||
|
|
|
@ -29,4 +29,11 @@ class DomainBlock < ApplicationRecord
|
||||||
def self.blocked?(domain)
|
def self.blocked?(domain)
|
||||||
where(domain: domain, severity: :suspend).exists?
|
where(domain: domain, severity: :suspend).exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def stricter_than?(other_block)
|
||||||
|
return true if suspend?
|
||||||
|
return false if other_block.suspend? && (silence? || noop?)
|
||||||
|
return false if other_block.silence? && noop?
|
||||||
|
(reject_media || !other_block.reject_media) && (reject_reports || !other_block.reject_reports)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
# account_id :bigint(8)
|
# account_id :bigint(8)
|
||||||
# description :text
|
# description :text
|
||||||
# scheduled_status_id :bigint(8)
|
# scheduled_status_id :bigint(8)
|
||||||
|
# blurhash :string
|
||||||
#
|
#
|
||||||
|
|
||||||
class MediaAttachment < ApplicationRecord
|
class MediaAttachment < ApplicationRecord
|
||||||
|
@ -34,6 +35,11 @@ class MediaAttachment < ApplicationRecord
|
||||||
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
|
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
|
||||||
AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
|
AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
|
||||||
|
|
||||||
|
BLURHASH_OPTIONS = {
|
||||||
|
x_comp: 4,
|
||||||
|
y_comp: 4,
|
||||||
|
}.freeze
|
||||||
|
|
||||||
IMAGE_STYLES = {
|
IMAGE_STYLES = {
|
||||||
original: {
|
original: {
|
||||||
pixels: 1_638_400, # 1280x1280px
|
pixels: 1_638_400, # 1280x1280px
|
||||||
|
@ -43,6 +49,7 @@ class MediaAttachment < ApplicationRecord
|
||||||
small: {
|
small: {
|
||||||
pixels: 160_000, # 400x400px
|
pixels: 160_000, # 400x400px
|
||||||
file_geometry_parser: FastGeometryParser,
|
file_geometry_parser: FastGeometryParser,
|
||||||
|
blurhash: BLURHASH_OPTIONS,
|
||||||
},
|
},
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
|
@ -71,6 +78,8 @@ class MediaAttachment < ApplicationRecord
|
||||||
},
|
},
|
||||||
format: 'png',
|
format: 'png',
|
||||||
time: 0,
|
time: 0,
|
||||||
|
file_geometry_parser: FastGeometryParser,
|
||||||
|
blurhash: BLURHASH_OPTIONS,
|
||||||
},
|
},
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
|
@ -186,13 +195,13 @@ class MediaAttachment < ApplicationRecord
|
||||||
|
|
||||||
def file_processors(f)
|
def file_processors(f)
|
||||||
if f.file_content_type == 'image/gif'
|
if f.file_content_type == 'image/gif'
|
||||||
[:gif_transcoder]
|
[:gif_transcoder, :blurhash_transcoder]
|
||||||
elsif VIDEO_MIME_TYPES.include? f.file_content_type
|
elsif VIDEO_MIME_TYPES.include? f.file_content_type
|
||||||
[:video_transcoder]
|
[:video_transcoder, :blurhash_transcoder]
|
||||||
elsif AUDIO_MIME_TYPES.include? f.file_content_type
|
elsif AUDIO_MIME_TYPES.include? f.file_content_type
|
||||||
[:audio_transcoder]
|
[:audio_transcoder]
|
||||||
else
|
else
|
||||||
[:lazy_thumbnail]
|
[:lazy_thumbnail, :blurhash_transcoder]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -78,7 +78,7 @@ class User < ApplicationRecord
|
||||||
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
|
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
|
||||||
|
|
||||||
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
|
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
|
||||||
validates_with BlacklistedEmailValidator, if: :email_changed?
|
validates_with BlacklistedEmailValidator, on: :create
|
||||||
validates_with EmailMxValidator, if: :validate_email_dns?
|
validates_with EmailMxValidator, if: :validate_email_dns?
|
||||||
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
|
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
|
||||||
|
|
||||||
|
@ -107,13 +107,14 @@ class User < ApplicationRecord
|
||||||
:expand_spoilers, :default_language, :aggregate_reblogs, :show_application, to: :settings, prefix: :setting, allow_nil: false
|
:expand_spoilers, :default_language, :aggregate_reblogs, :show_application, to: :settings, prefix: :setting, allow_nil: false
|
||||||
|
|
||||||
attr_reader :invite_code
|
attr_reader :invite_code
|
||||||
|
attr_writer :external
|
||||||
|
|
||||||
def confirmed?
|
def confirmed?
|
||||||
confirmed_at.present?
|
confirmed_at.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def invited?
|
def invited?
|
||||||
invite_id.present?
|
invite_id.present? && invite.valid_for_use?
|
||||||
end
|
end
|
||||||
|
|
||||||
def disable!
|
def disable!
|
||||||
|
@ -273,13 +274,17 @@ class User < ApplicationRecord
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_approved
|
def set_approved
|
||||||
self.approved = open_registrations? || invited?
|
self.approved = open_registrations? || invited? || external?
|
||||||
end
|
end
|
||||||
|
|
||||||
def open_registrations?
|
def open_registrations?
|
||||||
Setting.registrations_mode == 'open'
|
Setting.registrations_mode == 'open'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def external?
|
||||||
|
!!@external
|
||||||
|
end
|
||||||
|
|
||||||
def sanitize_languages
|
def sanitize_languages
|
||||||
return if chosen_languages.nil?
|
return if chosen_languages.nil?
|
||||||
chosen_languages.reject!(&:blank?)
|
chosen_languages.reject!(&:blank?)
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
context_extensions :atom_uri, :conversation, :sensitive,
|
context_extensions :atom_uri, :conversation, :sensitive,
|
||||||
:hashtag, :emoji, :focal_point
|
:hashtag, :emoji, :focal_point, :blurhash
|
||||||
|
|
||||||
attributes :id, :type, :summary,
|
attributes :id, :type, :summary,
|
||||||
:in_reply_to, :published, :url,
|
:in_reply_to, :published, :url,
|
||||||
|
@ -153,7 +153,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||||
class MediaAttachmentSerializer < ActivityPub::Serializer
|
class MediaAttachmentSerializer < ActivityPub::Serializer
|
||||||
include RoutingHelper
|
include RoutingHelper
|
||||||
|
|
||||||
attributes :type, :media_type, :url, :name
|
attributes :type, :media_type, :url, :name, :blurhash
|
||||||
attribute :focal_point, if: :focal_point?
|
attribute :focal_point, if: :focal_point?
|
||||||
|
|
||||||
def type
|
def type
|
||||||
|
|
|
@ -5,7 +5,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
|
||||||
|
|
||||||
attributes :id, :type, :url, :preview_url,
|
attributes :id, :type, :url, :preview_url,
|
||||||
:remote_url, :text_url, :meta,
|
:remote_url, :text_url, :meta,
|
||||||
:description
|
:description, :blurhash
|
||||||
|
|
||||||
def id
|
def id
|
||||||
object.id.to_s
|
object.id.to_s
|
||||||
|
|
|
@ -6,6 +6,7 @@ class BlockService < BaseService
|
||||||
|
|
||||||
UnfollowService.new.call(account, target_account) if account.following?(target_account)
|
UnfollowService.new.call(account, target_account) if account.following?(target_account)
|
||||||
UnfollowService.new.call(target_account, account) if target_account.following?(account)
|
UnfollowService.new.call(target_account, account) if target_account.following?(account)
|
||||||
|
RejectFollowService.new.call(account, target_account) if target_account.requested?(account)
|
||||||
|
|
||||||
block = account.block!(target_account)
|
block = account.block!(target_account)
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,10 @@
|
||||||
|
|
||||||
class BlacklistedEmailValidator < ActiveModel::Validator
|
class BlacklistedEmailValidator < ActiveModel::Validator
|
||||||
def validate(user)
|
def validate(user)
|
||||||
|
return if user.invited?
|
||||||
|
|
||||||
@email = user.email
|
@email = user.email
|
||||||
|
|
||||||
user.errors.add(:email, I18n.t('users.invalid_email')) if blocked_email?
|
user.errors.add(:email, I18n.t('users.invalid_email')) if blocked_email?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -13,7 +16,7 @@ class BlacklistedEmailValidator < ActiveModel::Validator
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_blacklist?
|
def on_blacklist?
|
||||||
return true if EmailDomainBlock.block?(@email)
|
return true if EmailDomainBlock.block?(@email)
|
||||||
return false if Rails.configuration.x.email_domains_blacklist.blank?
|
return false if Rails.configuration.x.email_domains_blacklist.blank?
|
||||||
|
|
||||||
domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
|
domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
- elsif !status.media_attachments.empty?
|
- elsif !status.media_attachments.empty?
|
||||||
- if status.media_attachments.first.video?
|
- if status.media_attachments.first.video?
|
||||||
- video = status.media_attachments.first
|
- video = status.media_attachments.first
|
||||||
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
|
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
|
||||||
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
|
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
|
||||||
- else
|
- else
|
||||||
= react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
= react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
- elsif !status.media_attachments.empty?
|
- elsif !status.media_attachments.empty?
|
||||||
- if status.media_attachments.first.video?
|
- if status.media_attachments.first.video?
|
||||||
- video = status.media_attachments.first
|
- video = status.media_attachments.first
|
||||||
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
|
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
|
||||||
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
|
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
|
||||||
- else
|
- else
|
||||||
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||||
|
|
|
@ -7,5 +7,7 @@ class ActivityPub::ProcessingWorker
|
||||||
|
|
||||||
def perform(account_id, body, delivered_to_account_id = nil)
|
def perform(account_id, body, delivered_to_account_id = nil)
|
||||||
ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true)
|
ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true)
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Rails.logger.debug "Error processing incoming ActivityPub object: #{e}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, req|
|
ActiveSupport::Notifications.subscribe(/rack_attack/) do |_name, _start, _finish, _request_id, payload|
|
||||||
|
req = payload[:request]
|
||||||
|
|
||||||
next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
|
next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
|
||||||
Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
|
Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
|
||||||
end
|
end
|
||||||
|
|
|
@ -269,6 +269,7 @@ co:
|
||||||
created_msg: U blucchime di u duminiu hè attivu
|
created_msg: U blucchime di u duminiu hè attivu
|
||||||
destroyed_msg: U blucchime di u duminiu ùn hè più attivu
|
destroyed_msg: U blucchime di u duminiu ùn hè più attivu
|
||||||
domain: Duminiu
|
domain: Duminiu
|
||||||
|
existing_domain_block_html: Avete digià impostu limite più strette nant'à %{name}, duvete <a href="%{unblock_url}">sbluccallu</a> primu.
|
||||||
new:
|
new:
|
||||||
create: Creà un blucchime
|
create: Creà un blucchime
|
||||||
hint: U blucchime di duminiu ùn impedirà micca a creazione di conti indè a database, mà metudi di muderazione specifiche saranu applicati.
|
hint: U blucchime di duminiu ùn impedirà micca a creazione di conti indè a database, mà metudi di muderazione specifiche saranu applicati.
|
||||||
|
|
|
@ -273,6 +273,7 @@ cs:
|
||||||
created_msg: Blokace domény se právě vyřizuje
|
created_msg: Blokace domény se právě vyřizuje
|
||||||
destroyed_msg: Blokace domény byla zrušena
|
destroyed_msg: Blokace domény byla zrušena
|
||||||
domain: Doména
|
domain: Doména
|
||||||
|
existing_domain_block_html: Pro účet %{name} jste již nastavil/a přísnější omezení, musíte jej nejdříve <a href="%{unblock_url}">odblokovat</a>.
|
||||||
new:
|
new:
|
||||||
create: Vytvořit blokaci
|
create: Vytvořit blokaci
|
||||||
hint: Blokace domény nezakáže vytváření záznamů účtů v databázi, ale bude na tyto účty zpětně a automaticky aplikovat specifické metody moderování.
|
hint: Blokace domény nezakáže vytváření záznamů účtů v databázi, ale bude na tyto účty zpětně a automaticky aplikovat specifické metody moderování.
|
||||||
|
|
|
@ -270,6 +270,7 @@ en:
|
||||||
created_msg: Domain block is now being processed
|
created_msg: Domain block is now being processed
|
||||||
destroyed_msg: Domain block has been undone
|
destroyed_msg: Domain block has been undone
|
||||||
domain: Domain
|
domain: Domain
|
||||||
|
existing_domain_block_html: You have already imposed stricter limits on %{name}, you need to <a href="%{unblock_url}">unblock it</a> first.
|
||||||
new:
|
new:
|
||||||
create: Create block
|
create: Create block
|
||||||
hint: The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.
|
hint: The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.
|
||||||
|
|
|
@ -260,10 +260,10 @@ fr:
|
||||||
title: Nouveau blocage de domaine
|
title: Nouveau blocage de domaine
|
||||||
reject_media: Fichiers média rejetés
|
reject_media: Fichiers média rejetés
|
||||||
reject_media_hint: Supprime localement les fichiers média stockés et refuse d’en télécharger ultérieurement. Ne concerne pas les suspensions
|
reject_media_hint: Supprime localement les fichiers média stockés et refuse d’en télécharger ultérieurement. Ne concerne pas les suspensions
|
||||||
reject_reports: Rapports de rejet
|
reject_reports: Rejeter les signalements
|
||||||
reject_reports_hint: Ignorez tous les rapports provenant de ce domaine. Sans objet pour les suspensions
|
reject_reports_hint: Ignorez tous les signalements provenant de ce domaine. Ne concerne pas les suspensions
|
||||||
rejecting_media: rejet des fichiers multimédia
|
rejecting_media: rejet des fichiers multimédia
|
||||||
rejecting_reports: rejet de rapports
|
rejecting_reports: rejet des signalements
|
||||||
severity:
|
severity:
|
||||||
silence: silencié
|
silence: silencié
|
||||||
suspend: suspendu
|
suspend: suspendu
|
||||||
|
|
|
@ -268,10 +268,11 @@ sk:
|
||||||
week_users_active: aktívni tento týždeň
|
week_users_active: aktívni tento týždeň
|
||||||
week_users_new: užívateľov počas tohto týždňa
|
week_users_new: užívateľov počas tohto týždňa
|
||||||
domain_blocks:
|
domain_blocks:
|
||||||
add_new: Pridaj nové doménové blokovanie
|
add_new: Blokuj novú doménu
|
||||||
created_msg: Doména je v štádiu blokovania
|
created_msg: Doména je v štádiu blokovania
|
||||||
destroyed_msg: Blokovanie domény bolo zrušené
|
destroyed_msg: Blokovanie domény bolo zrušené
|
||||||
domain: Doména
|
domain: Doména
|
||||||
|
existing_domain_block_html: Pre účet %{name} si už nahodil/a přísnejšie obmedzenie, najskôr ho teda musíš <a href="%{unblock_url}">odblokovať</a>.
|
||||||
new:
|
new:
|
||||||
create: Vytvor blokovanie domény
|
create: Vytvor blokovanie domény
|
||||||
hint: Blokovanie domény stále dovolí vytvárať nové účty v databázi, ale tieto budú spätne automaticky moderované.
|
hint: Blokovanie domény stále dovolí vytvárať nové účty v databázi, ale tieto budú spätne automaticky moderované.
|
||||||
|
@ -299,7 +300,7 @@ sk:
|
||||||
silence: Zruš stíšenie všetkých existujúcich účtov z tejto domény
|
silence: Zruš stíšenie všetkých existujúcich účtov z tejto domény
|
||||||
suspend: Zruš suspendáciu všetkých existujúcich účtov z tejto domény
|
suspend: Zruš suspendáciu všetkých existujúcich účtov z tejto domény
|
||||||
title: Zruš blokovanie domény %{domain}
|
title: Zruš blokovanie domény %{domain}
|
||||||
undo: Vrátiť späť
|
undo: Vráť späť
|
||||||
undo: Odvolaj blokovanie domény
|
undo: Odvolaj blokovanie domény
|
||||||
email_domain_blocks:
|
email_domain_blocks:
|
||||||
add_new: Pridaj nový
|
add_new: Pridaj nový
|
||||||
|
@ -527,16 +528,17 @@ sk:
|
||||||
login: Prihlás sa
|
login: Prihlás sa
|
||||||
logout: Odhlás sa
|
logout: Odhlás sa
|
||||||
migrate_account: Presúvam sa na iný účet
|
migrate_account: Presúvam sa na iný účet
|
||||||
migrate_account_html: Pokiaľ si želáš presmerovať tento účet na nejaký iný, môžeš si to <a href="%{path}">nastaviť tu</a>.
|
migrate_account_html: Ak si želáš presmerovať tento účet na nejaký iný, môžeš si to <a href="%{path}">nastaviť tu</a>.
|
||||||
or_log_in_with: Alebo prihlásiť z
|
or_log_in_with: Alebo prihlás s
|
||||||
providers:
|
providers:
|
||||||
cas: CAS
|
cas: CAS
|
||||||
saml: SAML
|
saml: SAML
|
||||||
register: Zaregistruj sa
|
register: Zaregistruj sa
|
||||||
resend_confirmation: Poslať potvrdzujúce pokyny znovu
|
resend_confirmation: Zašli potvrdzujúce pokyny znovu
|
||||||
reset_password: Obnov heslo
|
reset_password: Obnov heslo
|
||||||
security: Zabezpečenie
|
security: Zabezpečenie
|
||||||
set_new_password: Nastav nové heslo
|
set_new_password: Nastav nové heslo
|
||||||
|
trouble_logging_in: Problém s prihlásením?
|
||||||
authorize_follow:
|
authorize_follow:
|
||||||
already_following: Tento účet už následuješ
|
already_following: Tento účet už následuješ
|
||||||
error: Naneštastie nastala chyba pri hľadaní vzdialeného účtu
|
error: Naneštastie nastala chyba pri hľadaní vzdialeného účtu
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddBlurhashToMediaAttachments < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
add_column :media_attachments, :blurhash, :string
|
||||||
|
end
|
||||||
|
end
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2019_04_09_054914) do
|
ActiveRecord::Schema.define(version: 2019_04_20_025523) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -373,6 +373,7 @@ ActiveRecord::Schema.define(version: 2019_04_09_054914) do
|
||||||
t.bigint "account_id"
|
t.bigint "account_id"
|
||||||
t.text "description"
|
t.text "description"
|
||||||
t.bigint "scheduled_status_id"
|
t.bigint "scheduled_status_id"
|
||||||
|
t.string "blurhash"
|
||||||
t.index ["account_id"], name: "index_media_attachments_on_account_id"
|
t.index ["account_id"], name: "index_media_attachments_on_account_id"
|
||||||
t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
|
t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
|
||||||
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
|
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
|
||||||
|
|
|
@ -9,6 +9,7 @@ require_relative 'mastodon/search_cli'
|
||||||
require_relative 'mastodon/settings_cli'
|
require_relative 'mastodon/settings_cli'
|
||||||
require_relative 'mastodon/statuses_cli'
|
require_relative 'mastodon/statuses_cli'
|
||||||
require_relative 'mastodon/domains_cli'
|
require_relative 'mastodon/domains_cli'
|
||||||
|
require_relative 'mastodon/cache_cli'
|
||||||
require_relative 'mastodon/version'
|
require_relative 'mastodon/version'
|
||||||
|
|
||||||
module Mastodon
|
module Mastodon
|
||||||
|
@ -41,6 +42,9 @@ module Mastodon
|
||||||
desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
|
desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
|
||||||
subcommand 'domains', Mastodon::DomainsCLI
|
subcommand 'domains', Mastodon::DomainsCLI
|
||||||
|
|
||||||
|
desc 'cache SUBCOMMAND ...ARGS', 'Manage cache'
|
||||||
|
subcommand 'cache', Mastodon::CacheCLI
|
||||||
|
|
||||||
option :dry_run, type: :boolean
|
option :dry_run, type: :boolean
|
||||||
desc 'self-destruct', 'Erase the server from the federation'
|
desc 'self-destruct', 'Erase the server from the federation'
|
||||||
long_desc <<~LONG_DESC
|
long_desc <<~LONG_DESC
|
||||||
|
|
|
@ -73,7 +73,7 @@ module Mastodon
|
||||||
def create(username)
|
def create(username)
|
||||||
account = Account.new(username: username)
|
account = Account.new(username: username)
|
||||||
password = SecureRandom.hex
|
password = SecureRandom.hex
|
||||||
user = User.new(email: options[:email], password: password, agreement: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil)
|
user = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil)
|
||||||
|
|
||||||
if options[:reattach]
|
if options[:reattach]
|
||||||
account = Account.find_local(username) || Account.new(username: username)
|
account = Account.find_local(username) || Account.new(username: username)
|
||||||
|
@ -115,6 +115,7 @@ module Mastodon
|
||||||
option :enable, type: :boolean
|
option :enable, type: :boolean
|
||||||
option :disable, type: :boolean
|
option :disable, type: :boolean
|
||||||
option :disable_2fa, type: :boolean
|
option :disable_2fa, type: :boolean
|
||||||
|
option :approve, type: :boolean
|
||||||
desc 'modify USERNAME', 'Modify a user'
|
desc 'modify USERNAME', 'Modify a user'
|
||||||
long_desc <<-LONG_DESC
|
long_desc <<-LONG_DESC
|
||||||
Modify a user account.
|
Modify a user account.
|
||||||
|
@ -128,6 +129,9 @@ module Mastodon
|
||||||
With the --disable option, lock the user out of their account. The
|
With the --disable option, lock the user out of their account. The
|
||||||
--enable option is the opposite.
|
--enable option is the opposite.
|
||||||
|
|
||||||
|
With the --approve option, the account will be approved, if it was
|
||||||
|
previously not due to not having open registrations.
|
||||||
|
|
||||||
With the --disable-2fa option, the two-factor authentication
|
With the --disable-2fa option, the two-factor authentication
|
||||||
requirement for the user can be removed.
|
requirement for the user can be removed.
|
||||||
LONG_DESC
|
LONG_DESC
|
||||||
|
@ -147,6 +151,7 @@ module Mastodon
|
||||||
user.email = options[:email] if options[:email]
|
user.email = options[:email] if options[:email]
|
||||||
user.disabled = false if options[:enable]
|
user.disabled = false if options[:enable]
|
||||||
user.disabled = true if options[:disable]
|
user.disabled = true if options[:disable]
|
||||||
|
user.approved = true if options[:approve]
|
||||||
user.otp_required_for_login = false if options[:disable_2fa]
|
user.otp_required_for_login = false if options[:disable_2fa]
|
||||||
user.confirm if options[:confirm]
|
user.confirm if options[:confirm]
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative '../../config/boot'
|
||||||
|
require_relative '../../config/environment'
|
||||||
|
require_relative 'cli_helper'
|
||||||
|
|
||||||
|
module Mastodon
|
||||||
|
class CacheCLI < Thor
|
||||||
|
def self.exit_on_failure?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
desc 'clear', 'Clear out the cache storage'
|
||||||
|
def clear
|
||||||
|
Rails.cache.clear
|
||||||
|
say('OK', :green)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -13,7 +13,7 @@ module Mastodon
|
||||||
end
|
end
|
||||||
|
|
||||||
def patch
|
def patch
|
||||||
0
|
1
|
||||||
end
|
end
|
||||||
|
|
||||||
def pre
|
def pre
|
||||||
|
@ -46,7 +46,7 @@ module Mastodon
|
||||||
|
|
||||||
# specify git tag or commit hash here
|
# specify git tag or commit hash here
|
||||||
def source_tag
|
def source_tag
|
||||||
nil
|
ENV.fetch('SOURCE_TAG') { nil }
|
||||||
end
|
end
|
||||||
|
|
||||||
def source_url
|
def source_url
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Paperclip
|
||||||
|
class BlurhashTranscoder < Paperclip::Processor
|
||||||
|
def make
|
||||||
|
return @file unless options[:style] == :small
|
||||||
|
|
||||||
|
pixels = convert(':source RGB:-', source: File.expand_path(@file.path)).unpack('C*')
|
||||||
|
geometry = options.fetch(:file_geometry_parser).from_file(@file)
|
||||||
|
|
||||||
|
attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, options[:blurhash] || {})
|
||||||
|
|
||||||
|
@file
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -80,6 +80,7 @@
|
||||||
"babel-plugin-react-intl": "^3.0.1",
|
"babel-plugin-react-intl": "^3.0.1",
|
||||||
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
|
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
|
||||||
"babel-runtime": "^6.26.0",
|
"babel-runtime": "^6.26.0",
|
||||||
|
"blurhash": "^1.0.0",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
"compression-webpack-plugin": "^2.0.0",
|
"compression-webpack-plugin": "^2.0.0",
|
||||||
"cross-env": "^5.1.4",
|
"cross-env": "^5.1.4",
|
||||||
|
|
|
@ -2,3 +2,4 @@
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow: /media_proxy/
|
Disallow: /media_proxy/
|
||||||
|
Disallow: /interact/
|
||||||
|
|
|
@ -37,7 +37,7 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renders new when failed to save' do
|
it 'renders new when failed to save' do
|
||||||
Fabricate(:domain_block, domain: 'example.com')
|
Fabricate(:domain_block, domain: 'example.com', severity: 'suspend')
|
||||||
allow(DomainBlockWorker).to receive(:perform_async).and_return(true)
|
allow(DomainBlockWorker).to receive(:perform_async).and_return(true)
|
||||||
|
|
||||||
post :create, params: { domain_block: { domain: 'example.com', severity: 'silence' } }
|
post :create, params: { domain_block: { domain: 'example.com', severity: 'silence' } }
|
||||||
|
@ -45,6 +45,17 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
|
||||||
expect(DomainBlockWorker).not_to have_received(:perform_async)
|
expect(DomainBlockWorker).not_to have_received(:perform_async)
|
||||||
expect(response).to render_template :new
|
expect(response).to render_template :new
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'allows upgrading a block' do
|
||||||
|
Fabricate(:domain_block, domain: 'example.com', severity: 'silence')
|
||||||
|
allow(DomainBlockWorker).to receive(:perform_async).and_return(true)
|
||||||
|
|
||||||
|
post :create, params: { domain_block: { domain: 'example.com', severity: 'silence', reject_media: true, reject_reports: true } }
|
||||||
|
|
||||||
|
expect(DomainBlockWorker).to have_received(:perform_async)
|
||||||
|
expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.created_msg')
|
||||||
|
expect(response).to redirect_to(admin_instances_path(limited: '1'))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'DELETE #destroy' do
|
describe 'DELETE #destroy' do
|
||||||
|
|
|
@ -107,6 +107,89 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'approval-based registrations without invite' do
|
||||||
|
around do |example|
|
||||||
|
registrations_mode = Setting.registrations_mode
|
||||||
|
example.run
|
||||||
|
Setting.registrations_mode = registrations_mode
|
||||||
|
end
|
||||||
|
|
||||||
|
subject do
|
||||||
|
Setting.registrations_mode = 'approved'
|
||||||
|
request.headers["Accept-Language"] = accept_language
|
||||||
|
post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678' } }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redirects to login page' do
|
||||||
|
subject
|
||||||
|
expect(response).to redirect_to new_user_session_path
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates user' do
|
||||||
|
subject
|
||||||
|
user = User.find_by(email: 'test@example.com')
|
||||||
|
expect(user).to_not be_nil
|
||||||
|
expect(user.locale).to eq(accept_language)
|
||||||
|
expect(user.approved).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'approval-based registrations with expired invite' do
|
||||||
|
around do |example|
|
||||||
|
registrations_mode = Setting.registrations_mode
|
||||||
|
example.run
|
||||||
|
Setting.registrations_mode = registrations_mode
|
||||||
|
end
|
||||||
|
|
||||||
|
subject do
|
||||||
|
Setting.registrations_mode = 'approved'
|
||||||
|
request.headers["Accept-Language"] = accept_language
|
||||||
|
invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.ago)
|
||||||
|
post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', 'invite_code': invite.code } }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redirects to login page' do
|
||||||
|
subject
|
||||||
|
expect(response).to redirect_to new_user_session_path
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates user' do
|
||||||
|
subject
|
||||||
|
user = User.find_by(email: 'test@example.com')
|
||||||
|
expect(user).to_not be_nil
|
||||||
|
expect(user.locale).to eq(accept_language)
|
||||||
|
expect(user.approved).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'approval-based registrations with valid invite' do
|
||||||
|
around do |example|
|
||||||
|
registrations_mode = Setting.registrations_mode
|
||||||
|
example.run
|
||||||
|
Setting.registrations_mode = registrations_mode
|
||||||
|
end
|
||||||
|
|
||||||
|
subject do
|
||||||
|
Setting.registrations_mode = 'approved'
|
||||||
|
request.headers["Accept-Language"] = accept_language
|
||||||
|
invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.from_now)
|
||||||
|
post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', 'invite_code': invite.code } }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'redirects to login page' do
|
||||||
|
subject
|
||||||
|
expect(response).to redirect_to new_user_session_path
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates user' do
|
||||||
|
subject
|
||||||
|
user = User.find_by(email: 'test@example.com')
|
||||||
|
expect(user).to_not be_nil
|
||||||
|
expect(user.locale).to eq(accept_language)
|
||||||
|
expect(user.approved).to eq(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
it 'does nothing if user already exists' do
|
it 'does nothing if user already exists' do
|
||||||
Fabricate(:user, account: Fabricate(:account, username: 'test'))
|
Fabricate(:user, account: Fabricate(:account, username: 'test'))
|
||||||
subject
|
subject
|
||||||
|
|
|
@ -36,4 +36,35 @@ RSpec.describe DomainBlock, type: :model do
|
||||||
expect(DomainBlock.blocked?('domain')).to eq false
|
expect(DomainBlock.blocked?('domain')).to eq false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'stricter_than?' do
|
||||||
|
it 'returns true if the new block has suspend severity while the old has lower severity' do
|
||||||
|
suspend = DomainBlock.new(domain: 'domain', severity: :suspend)
|
||||||
|
silence = DomainBlock.new(domain: 'domain', severity: :silence)
|
||||||
|
noop = DomainBlock.new(domain: 'domain', severity: :noop)
|
||||||
|
expect(suspend.stricter_than?(silence)).to be true
|
||||||
|
expect(suspend.stricter_than?(noop)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false if the new block has lower severity than the old one' do
|
||||||
|
suspend = DomainBlock.new(domain: 'domain', severity: :suspend)
|
||||||
|
silence = DomainBlock.new(domain: 'domain', severity: :silence)
|
||||||
|
noop = DomainBlock.new(domain: 'domain', severity: :noop)
|
||||||
|
expect(silence.stricter_than?(suspend)).to be false
|
||||||
|
expect(noop.stricter_than?(suspend)).to be false
|
||||||
|
expect(noop.stricter_than?(silence)).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false if the new block does is less strict regarding reports' do
|
||||||
|
older = DomainBlock.new(domain: 'domain', severity: :silence, reject_reports: true)
|
||||||
|
newer = DomainBlock.new(domain: 'domain', severity: :silence, reject_reports: false)
|
||||||
|
expect(newer.stricter_than?(older)).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false if the new block does is less strict regarding media' do
|
||||||
|
older = DomainBlock.new(domain: 'domain', severity: :silence, reject_media: true)
|
||||||
|
newer = DomainBlock.new(domain: 'domain', severity: :silence, reject_media: false)
|
||||||
|
expect(newer.stricter_than?(older)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,6 +8,7 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do
|
||||||
let(:errors) { double(add: nil) }
|
let(:errors) { double(add: nil) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
allow(user).to receive(:invited?) { false }
|
||||||
allow_any_instance_of(described_class).to receive(:blocked_email?) { blocked_email }
|
allow_any_instance_of(described_class).to receive(:blocked_email?) { blocked_email }
|
||||||
described_class.new.validate(user)
|
described_class.new.validate(user)
|
||||||
end
|
end
|
||||||
|
|
|
@ -1747,6 +1747,11 @@ bluebird@^3.5.1, bluebird@^3.5.3:
|
||||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
|
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
|
||||||
integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw==
|
integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw==
|
||||||
|
|
||||||
|
blurhash@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.0.0.tgz#9087bc5cc4d482f1305059d7410df4133adcab2e"
|
||||||
|
integrity sha512-x6fpZnd6AWde4U9m7xhUB44qIvGV4W6OdTAXGabYm4oZUOOGh5K1HAEoGAQn3iG4gbbPn9RSGce3VfNgGsX/Vw==
|
||||||
|
|
||||||
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
|
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
|
||||||
version "4.11.8"
|
version "4.11.8"
|
||||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
|
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
|
||||||
|
|
Loading…
Reference in New Issue