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

Merge upstream changes
main
ThibG 6 years ago committed by GitHub
commit e4ca5414a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -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'

@ -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,7 +13,18 @@ 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 existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save
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
@domain_block.errors[:domain].clear
render :new
else
if existing_domain_block.present?
@domain_block = existing_domain_block
@domain_block.update(resource_params)
end
if @domain_block.save if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id) DomainBlockWorker.perform_async(@domain_block.id)
log_action :create, @domain_block log_action :create, @domain_block
@ -22,6 +33,7 @@ module Admin
render :new render :new
end end
end end
end
def show def show
authorize @domain_block, :show? authorize @domain_block, :show?

@ -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,7 +174,15 @@ 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']);
@ -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) {
let warning = <FormattedMessage {...(sensitive ? messages.warning : messages.hidden)} />;
children = (
<button className='media-spoiler' type='button' onClick={this.handleOpen}>
<span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage {...messages.toggle} /></span>
</button>
);
} else {
if (this.isStandaloneEligible()) { if (this.isStandaloneEligible()) {
children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} displayWidth={width} />; children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
} else { } 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} />); 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} />);
} }
if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
} else {
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>
);
} }
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'
onClick={this.handleOpen}
overlay
title={intl.formatMessage(messages.toggle_visible)}
/>
{sensitive ? (
<span className='sensitive-marker'> <span className='sensitive-marker'>
<FormattedMessage {...messages.sensitive} /> <FormattedMessage {...messages.sensitive} />
</span> </span>
) : null} )}
</div> </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; }
}
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 });
} }
return false; 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 width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
const focusY = media.getIn(['meta', 'focus', 'y']); const height = width;
const status = attachment.get('status');
const title = status.get('spoiler_text') || attachment.get('description');
let thumbnail = '';
if (attachment.get('type') === 'unknown') {
// Skip
} else if (attachment.get('type') === 'image') {
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
const x = ((focusX / 2) + .5) * 100; const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100; const y = ((focusY / -2) + .5) * 100;
const style = {};
let label, icon, title; 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;
if (media.get('type') === 'gifv') { thumbnail = (
label = <span className='media-gallery__gifv__label'>GIF</span>; <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>
);
} }
if (visible) { const icon = (
style.backgroundImage = `url(${media.get('preview_url')})`;
style.backgroundPosition = `${x}% ${y}%`;
title = media.get('description');
} else {
icon = (
<span className='account-gallery__item__icons'> <span className='account-gallery__item__icons'>
<i className='fa fa-eye-slash' /> <i className='fa fa-eye-slash' />
</span> </span>
); );
title = status.get('spoiler_text') || media.get('description');
}
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,10 +14,11 @@ 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']),
}); });
@ -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;
position: relative;
&::before {
content: "";
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; display: block;
width: 100%; position: relative;
height: 100%;
background: rgba($base-overlay-background, 0.3);
border-radius: 4px; border-radius: 4px;
} overflow: hidden;
} margin: 2px;
}
&__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,7 +154,15 @@ 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']);
@ -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; }
}
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();
}
} }
return false; 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 width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
const focusY = media.getIn(['meta', 'focus', 'y']); const height = width;
const status = attachment.get('status');
let thumbnail = '';
if (attachment.get('type') === 'unknown') {
// Skip
} else if (attachment.get('type') === 'image') {
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
const x = ((focusX / 2) + .5) * 100; const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100; const y = ((focusY / -2) + .5) * 100;
const style = {};
let label, icon; 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;
if (media.get('type') === 'gifv') { thumbnail = (
label = <span className='media-gallery__gifv__label'>GIF</span>; <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
/>
if (visible) { <span className='media-gallery__gifv__label'>GIF</span>
style.backgroundImage = `url(${media.get('preview_url')})`; </div>
style.backgroundPosition = `${x}% ${y}%`;
} else {
icon = (
<span className='account-gallery__item__icons'>
<Icon id='eye-slash' />
</span>
); );
} }
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,22 +2,23 @@ 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']),
}); });
@ -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,33 +34,15 @@ 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,
});
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> </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;
position: relative;
&::before {
content: "";
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; display: block;
width: 100%; position: relative;
height: 100%;
background: rgba($base-overlay-background, 0.3);
border-radius: 4px; border-radius: 4px;
} overflow: hidden;
} margin: 2px;
}
&__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

@ -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 den télécharger ultérieurement. Ne concerne pas les suspensions reject_media_hint: Supprime localement les fichiers média stockés et refuse den 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…
Cancel
Save