Merge branch 'master' into glitch-soc/merge-upstream
Conflicts: - app/models/media_attachment.rb
This commit is contained in:
commit
33c80e0783
39
CHANGELOG.md
39
CHANGELOG.md
@ -3,6 +3,45 @@ Changelog
|
||||
|
||||
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
|
||||
### Added
|
||||
|
||||
|
9
Gemfile
9
Gemfile
@ -21,6 +21,7 @@ gem 'fog-openstack', '~> 0.3', require: false
|
||||
gem 'paperclip', '~> 6.0'
|
||||
gem 'paperclip-av-transcoder', '~> 0.6'
|
||||
gem 'streamio-ffmpeg', '~> 3.0'
|
||||
gem 'blurhash', '~> 0.1'
|
||||
|
||||
gem 'active_model_serializers', '~> 0.10'
|
||||
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 'pundit', '~> 2.0'
|
||||
gem 'premailer-rails'
|
||||
gem 'rack-attack', '~> 5.4'
|
||||
gem 'rack-attack', '~> 6.0'
|
||||
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
|
||||
gem 'rails-i18n', '~> 5.1'
|
||||
gem 'rails-settings-cached', '~> 0.6'
|
||||
@ -124,14 +125,14 @@ group :development do
|
||||
gem 'annotate', '~> 2.7'
|
||||
gem 'better_errors', '~> 2.5'
|
||||
gem 'binding_of_caller', '~> 0.7'
|
||||
gem 'bullet', '~> 5.9'
|
||||
gem 'bullet', '~> 6.0'
|
||||
gem 'letter_opener', '~> 1.7'
|
||||
gem 'letter_opener_web', '~> 1.3'
|
||||
gem 'memory_profiler'
|
||||
gem 'rubocop', '~> 0.67', require: false
|
||||
gem 'rubocop', '~> 0.68', require: false
|
||||
gem 'brakeman', '~> 4.5', 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-rails', '~> 1.4'
|
||||
|
39
Gemfile.lock
39
Gemfile.lock
@ -66,8 +66,8 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 4.0)
|
||||
airbrussh (1.3.0)
|
||||
sshkit (>= 1.6.1, != 1.7.0)
|
||||
annotate (2.7.4)
|
||||
activerecord (>= 3.2, < 6.0)
|
||||
annotate (2.7.5)
|
||||
activerecord (>= 3.2, < 7.0)
|
||||
rake (>= 10.4, < 13.0)
|
||||
arel (9.0.0)
|
||||
ast (2.4.0)
|
||||
@ -99,12 +99,14 @@ GEM
|
||||
rack (>= 0.9.0)
|
||||
binding_of_caller (0.8.0)
|
||||
debug_inspector (>= 0.0.1)
|
||||
bootsnap (1.4.3)
|
||||
blurhash (0.1.2)
|
||||
ffi (~> 1.10.0)
|
||||
bootsnap (1.4.4)
|
||||
msgpack (~> 1.0)
|
||||
brakeman (4.5.0)
|
||||
browser (2.5.3)
|
||||
builder (3.2.3)
|
||||
bullet (5.9.0)
|
||||
bullet (6.0.0)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundler-audit (0.6.1)
|
||||
@ -205,7 +207,7 @@ GEM
|
||||
et-orbi (1.1.6)
|
||||
tzinfo
|
||||
excon (0.62.0)
|
||||
fabrication (2.20.1)
|
||||
fabrication (2.20.2)
|
||||
faker (1.9.3)
|
||||
i18n (>= 0.7)
|
||||
faraday (0.15.0)
|
||||
@ -348,7 +350,7 @@ GEM
|
||||
mini_mime (1.0.1)
|
||||
mini_portile2 (2.4.0)
|
||||
minitest (5.11.3)
|
||||
msgpack (1.2.9)
|
||||
msgpack (1.2.10)
|
||||
multi_json (1.13.1)
|
||||
multipart-post (2.0.0)
|
||||
necromancer (0.4.0)
|
||||
@ -395,7 +397,7 @@ GEM
|
||||
parallel (1.17.0)
|
||||
parallel_tests (2.28.0)
|
||||
parallel
|
||||
parser (2.6.2.1)
|
||||
parser (2.6.3.0)
|
||||
ast (~> 2.4.0)
|
||||
pastel (0.7.2)
|
||||
equatable (~> 0.5.0)
|
||||
@ -420,14 +422,13 @@ GEM
|
||||
pry (~> 0.10)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
psych (3.1.0)
|
||||
public_suffix (3.0.3)
|
||||
puma (3.12.1)
|
||||
pundit (2.0.1)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.1.6)
|
||||
rack (2.0.7)
|
||||
rack-attack (5.4.2)
|
||||
rack-attack (6.0.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-cors (1.0.3)
|
||||
rack-protection (2.0.5)
|
||||
@ -472,8 +473,8 @@ GEM
|
||||
rainbow (3.0.0)
|
||||
rake (12.3.2)
|
||||
rb-fsevent (0.10.3)
|
||||
rb-inotify (0.9.10)
|
||||
ffi (>= 0.5.0, < 2)
|
||||
rb-inotify (0.10.0)
|
||||
ffi (~> 1.0)
|
||||
rdf (3.0.9)
|
||||
hamster (~> 3.0)
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
@ -528,11 +529,10 @@ GEM
|
||||
rspec-core (~> 3.0, >= 3.0.0)
|
||||
sidekiq (>= 2.4.0)
|
||||
rspec-support (3.8.0)
|
||||
rubocop (0.67.2)
|
||||
rubocop (0.68.1)
|
||||
jaro_winkler (~> 1.5.1)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 2.5, != 2.5.1.1)
|
||||
psych (>= 3.1.0)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 1.6)
|
||||
@ -546,12 +546,12 @@ GEM
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.8.0)
|
||||
nokogumbo (~> 2.0)
|
||||
sass (3.6.0)
|
||||
sass (3.7.4)
|
||||
sass-listen (~> 4.0.0)
|
||||
sass-listen (4.0.0)
|
||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||
rb-inotify (~> 0.9, >= 0.9.7)
|
||||
scss_lint (0.57.1)
|
||||
scss_lint (0.58.0)
|
||||
rake (>= 0.9, < 13)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
sidekiq (5.2.7)
|
||||
@ -663,10 +663,11 @@ DEPENDENCIES
|
||||
aws-sdk-s3 (~> 1.36)
|
||||
better_errors (~> 2.5)
|
||||
binding_of_caller (~> 0.7)
|
||||
blurhash (~> 0.1)
|
||||
bootsnap (~> 1.4)
|
||||
brakeman (~> 4.5)
|
||||
browser
|
||||
bullet (~> 5.9)
|
||||
bullet (~> 6.0)
|
||||
bundler-audit (~> 0.6)
|
||||
capistrano (~> 3.11)
|
||||
capistrano-rails (~> 1.4)
|
||||
@ -737,7 +738,7 @@ DEPENDENCIES
|
||||
pry-rails (~> 0.3)
|
||||
puma (~> 3.12)
|
||||
pundit (~> 2.0)
|
||||
rack-attack (~> 5.4)
|
||||
rack-attack (~> 6.0)
|
||||
rack-cors (~> 1.0)
|
||||
rails (~> 5.2.3)
|
||||
rails-controller-testing (~> 1.0)
|
||||
@ -750,9 +751,9 @@ DEPENDENCIES
|
||||
rqrcode (~> 0.10)
|
||||
rspec-rails (~> 3.8)
|
||||
rspec-sidekiq (~> 3.0)
|
||||
rubocop (~> 0.67)
|
||||
rubocop (~> 0.68)
|
||||
sanitize (~> 5.0)
|
||||
scss_lint (~> 0.57)
|
||||
scss_lint (~> 0.58)
|
||||
sidekiq (~> 5.2)
|
||||
sidekiq-bulk (~> 0.2.0)
|
||||
sidekiq-scheduler (~> 3.0)
|
||||
|
@ -13,7 +13,18 @@ module Admin
|
||||
authorize :domain_block, :create?
|
||||
|
||||
@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
|
||||
DomainBlockWorker.perform_async(@domain_block.id)
|
||||
log_action :create, @domain_block
|
||||
@ -22,6 +33,7 @@ module Admin
|
||||
render :new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
authorize @domain_block, :show?
|
||||
|
@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController
|
||||
skip_before_action :store_current_location
|
||||
skip_before_action :check_user_permissions
|
||||
|
||||
before_action :set_cache_headers
|
||||
|
||||
protect_from_forgery with: :null_session
|
||||
|
||||
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
|
||||
@ -88,4 +90,8 @@ class Api::BaseController < ApplicationController
|
||||
def authorize_if_got_token!(*scopes)
|
||||
doorkeeper_authorize!(*scopes) if doorkeeper_token
|
||||
end
|
||||
|
||||
def set_cache_headers
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
|
||||
end
|
||||
end
|
||||
|
@ -3,6 +3,8 @@
|
||||
class Api::V1::CustomEmojisController < Api::BaseController
|
||||
respond_to :json
|
||||
|
||||
skip_before_action :set_cache_headers
|
||||
|
||||
def index
|
||||
render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
|
||||
ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
class Api::V1::Instances::ActivityController < Api::BaseController
|
||||
before_action :require_enabled_api!
|
||||
skip_before_action :set_cache_headers
|
||||
|
||||
respond_to :json
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
class Api::V1::Instances::PeersController < Api::BaseController
|
||||
before_action :require_enabled_api!
|
||||
skip_before_action :set_cache_headers
|
||||
|
||||
respond_to :json
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
class Api::V1::InstancesController < Api::BaseController
|
||||
respond_to :json
|
||||
skip_before_action :set_cache_headers
|
||||
|
||||
def show
|
||||
render_cached_json('api:v1:instances', expires_in: 5.minutes) do
|
||||
|
@ -96,7 +96,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def determine_layout
|
||||
|
@ -21,7 +21,7 @@ class Settings::NotificationsController < Settings::BaseController
|
||||
|
||||
def user_settings_params
|
||||
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)
|
||||
)
|
||||
end
|
||||
|
@ -203,8 +203,8 @@ export function uploadCompose(files) {
|
||||
return function (dispatch, getState) {
|
||||
const uploadLimit = 4;
|
||||
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);
|
||||
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
|
||||
|
||||
if (files.length + media.size > uploadLimit) {
|
||||
dispatch(showAlert(undefined, messages.uploadErrorLimit));
|
||||
@ -224,6 +224,8 @@ export function uploadCompose(files) {
|
||||
resizeImage(f).then(file => {
|
||||
const data = new FormData();
|
||||
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, {
|
||||
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 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 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 expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
|
||||
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 classNames from 'classnames';
|
||||
import { autoPlayGif, displayMedia } from '../initial_state';
|
||||
import { decode } from 'blurhash';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
||||
@ -21,6 +22,7 @@ class Item extends React.PureComponent {
|
||||
size: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
displayWidth: PropTypes.number,
|
||||
visible: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -29,6 +31,10 @@ class Item extends React.PureComponent {
|
||||
size: 1,
|
||||
};
|
||||
|
||||
state = {
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
handleMouseEnter = (e) => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.play();
|
||||
@ -62,8 +68,40 @@ class Item extends React.PureComponent {
|
||||
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 () {
|
||||
const { attachment, index, size, standalone, displayWidth } = this.props;
|
||||
const { attachment, index, size, standalone, displayWidth, visible } = this.props;
|
||||
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
@ -116,7 +154,15 @@ class Item extends React.PureComponent {
|
||||
|
||||
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 previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
||||
|
||||
@ -147,6 +193,7 @@ class Item extends React.PureComponent {
|
||||
alt={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
onLoad={this.handleImageLoad}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
@ -176,7 +223,8 @@ class Item extends React.PureComponent {
|
||||
|
||||
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}%` }}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent {
|
||||
if (node /*&& this.isStandaloneEligible()*/) {
|
||||
// offsetWidth triggers a layout, so only calculate when we need to
|
||||
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
||||
|
||||
this.setState({
|
||||
width: node.offsetWidth,
|
||||
});
|
||||
@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent {
|
||||
|
||||
const width = this.state.width || defaultWidth;
|
||||
|
||||
let children;
|
||||
let children, spoilerButton;
|
||||
|
||||
const style = {};
|
||||
|
||||
@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent {
|
||||
style.height = height;
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
children = (
|
||||
<button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
|
||||
<span className='media-spoiler__warning'>{warning}</span>
|
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
const size = media.take(4).size;
|
||||
|
||||
if (this.isStandaloneEligible()) {
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
|
||||
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} />);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
|
||||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent {
|
||||
if (status.get('poll')) {
|
||||
media = <PollContainer pollId={status.get('poll')} />;
|
||||
} 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 = (
|
||||
<AttachmentList
|
||||
compact
|
||||
@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent {
|
||||
{Component => (
|
||||
<Component
|
||||
preview={video.get('preview_url')}
|
||||
blurhash={video.get('blurhash')}
|
||||
src={video.get('url')}
|
||||
alt={video.get('description')}
|
||||
width={this.props.cachedMediaWidth}
|
||||
|
@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent {
|
||||
|
||||
handleMoveUp = (id, featured) => {
|
||||
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
|
||||
this._selectChild(elementIndex);
|
||||
this._selectChild(elementIndex, true);
|
||||
}
|
||||
|
||||
handleMoveDown = (id, featured) => {
|
||||
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
|
||||
this._selectChild(elementIndex);
|
||||
this._selectChild(elementIndex, false);
|
||||
}
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
|
||||
}, 300, { leading: true })
|
||||
|
||||
_selectChild (index) {
|
||||
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.node.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -1,62 +1,142 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Permalink from '../../../components/permalink';
|
||||
import { displayMedia } from '../../../initial_state';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
|
||||
import classNames from 'classnames';
|
||||
import { decode } from 'blurhash';
|
||||
import { isIOS } from 'mastodon/is_mobile';
|
||||
|
||||
export default class MediaItem extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
attachment: ImmutablePropTypes.map.isRequired,
|
||||
displayWidth: PropTypes.number.isRequired,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
if (!this.state.visible) {
|
||||
this.setState({ visible: true });
|
||||
return true;
|
||||
componentDidMount () {
|
||||
if (this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
_decode () {
|
||||
const hash = this.props.attachment.get('blurhash');
|
||||
const pixels = decode(hash, 32, 32);
|
||||
|
||||
if (pixels) {
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
const imageData = new ImageData(pixels, 32, 32);
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
}
|
||||
|
||||
handleImageLoad = () => {
|
||||
this.setState({ loaded: true });
|
||||
}
|
||||
|
||||
handleMouseEnter = e => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.play();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseLeave = e => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.pause();
|
||||
e.target.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
hoverToPlay () {
|
||||
return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.state.visible) {
|
||||
this.props.onOpenMedia(this.props.attachment);
|
||||
} else {
|
||||
this.setState({ visible: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media } = this.props;
|
||||
const { visible } = this.state;
|
||||
const status = media.get('status');
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
||||
const { attachment, displayWidth } = this.props;
|
||||
const { visible, loaded } = this.state;
|
||||
|
||||
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
|
||||
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 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') {
|
||||
label = <span className='media-gallery__gifv__label'>GIF</span>;
|
||||
}
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.get('description')}
|
||||
title={attachment.get('description')}
|
||||
role='application'
|
||||
src={attachment.get('url')}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
autoPlay={autoPlay}
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
|
||||
if (visible) {
|
||||
style.backgroundImage = `url(${media.get('preview_url')})`;
|
||||
style.backgroundPosition = `${x}% ${y}%`;
|
||||
} else {
|
||||
icon = (
|
||||
<span className='account-gallery__item__icons'>
|
||||
<Icon id='eye-slash' />
|
||||
</span>
|
||||
<span className='media-gallery__gifv__label'>GIF</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account-gallery__item'>
|
||||
<Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}>
|
||||
{icon}
|
||||
{label}
|
||||
</Permalink>
|
||||
<div className='account-gallery__item' style={{ width, height }}>
|
||||
<a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' style={{ cursor: 'pointer' }} onClick={this.handleClick}>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
|
||||
{visible && thumbnail}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -2,22 +2,23 @@ import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { fetchAccount } from '../../actions/accounts';
|
||||
import { fetchAccount } from 'mastodon/actions/accounts';
|
||||
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 ColumnBackButton from '../../components/column_back_button';
|
||||
import ColumnBackButton from 'mastodon/components/column_back_button';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { getAccountGallery } from '../../selectors';
|
||||
import { getAccountGallery } from 'mastodon/selectors';
|
||||
import MediaItem from './components/media_item';
|
||||
import HeaderContainer from '../account_timeline/containers/header_container';
|
||||
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 { openModal } from 'mastodon/actions/modal';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
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']),
|
||||
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
|
||||
});
|
||||
@ -51,12 +52,16 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
medias: ImmutablePropTypes.list.isRequired,
|
||||
attachments: ImmutablePropTypes.list.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isAccount: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
width: 323,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
|
||||
@ -71,11 +76,11 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
|
||||
handleScrollToBottom = () => {
|
||||
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 offset = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
@ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
|
||||
};
|
||||
|
||||
handleLoadOlder = (e) => {
|
||||
handleLoadOlder = e => {
|
||||
e.preventDefault();
|
||||
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 () {
|
||||
const { medias, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
|
||||
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
|
||||
const { width } = this.state;
|
||||
|
||||
if (!isAccount) {
|
||||
return (
|
||||
@ -104,9 +127,7 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
let loadOlder = null;
|
||||
|
||||
if (!medias && isLoading) {
|
||||
if (!attachments && isLoading) {
|
||||
return (
|
||||
<Column>
|
||||
<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} />;
|
||||
}
|
||||
|
||||
@ -126,23 +149,17 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
|
||||
<HeaderContainer accountId={this.props.params.accountId} />
|
||||
|
||||
<div role='feed' className='account-gallery__container'>
|
||||
{medias.map((media, index) => media === null ? (
|
||||
<LoadMoreMedia
|
||||
key={'more:' + medias.getIn(index + 1, 'id')}
|
||||
maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
/>
|
||||
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
|
||||
{attachments.map((attachment, index) => attachment === null ? (
|
||||
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
|
||||
) : (
|
||||
<MediaItem
|
||||
key={media.get('id')}
|
||||
media={media}
|
||||
/>
|
||||
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
|
||||
))}
|
||||
|
||||
{loadOlder}
|
||||
</div>
|
||||
|
||||
{isLoading && medias.size === 0 && (
|
||||
{isLoading && attachments.size === 0 && (
|
||||
<div className='scrollable__append'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
|
@ -10,7 +10,6 @@ import UploadButtonContainer from '../containers/upload_button_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||
import PollFormContainer from '../containers/poll_form_container';
|
||||
import UploadFormContainer from '../containers/upload_form_container';
|
||||
@ -215,7 +214,6 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
<UploadButtonContainer />
|
||||
<PollButtonContainer />
|
||||
<PrivacyDropdownContainer />
|
||||
<SensitiveButtonContainer />
|
||||
<SpoilerButtonContainer />
|
||||
</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 ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import UploadContainer from '../containers/upload_container';
|
||||
import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
||||
|
||||
export default class UploadForm extends ImmutablePureComponent {
|
||||
|
||||
@ -22,6 +23,8 @@ export default class UploadForm extends ImmutablePureComponent {
|
||||
<UploadContainer id={id} key={id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!mediaIds.isEmpty() && <SensitiveButtonContainer />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -2,11 +2,9 @@ import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { changeComposeSensitivity } from '../../../actions/compose';
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import { changeComposeSensitivity } from 'mastodon/actions/compose';
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
|
||||
@ -14,7 +12,6 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
visible: state.getIn(['compose', 'media_attachments']).size > 0,
|
||||
active: state.getIn(['compose', 'sensitive']),
|
||||
disabled: state.getIn(['compose', 'spoiler']),
|
||||
});
|
||||
@ -30,7 +27,6 @@ const mapDispatchToProps = dispatch => ({
|
||||
class SensitiveButton extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
visible: PropTypes.bool,
|
||||
active: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
@ -38,33 +34,15 @@ class SensitiveButton extends React.PureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { visible, active, disabled, onClick, intl } = this.props;
|
||||
const { active, disabled, onClick, intl } = this.props;
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
|
||||
{({ scale }) => {
|
||||
const icon = active ? 'eye-slash' : 'eye';
|
||||
const className = classNames('compose-form__sensitive-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 className='compose-form__sensitive-button'>
|
||||
<button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
|
||||
<Icon id='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -20,18 +20,24 @@ export default class ConversationsList extends ImmutablePureComponent {
|
||||
|
||||
handleMoveUp = id => {
|
||||
const elementIndex = this.getCurrentIndex(id) - 1;
|
||||
this._selectChild(elementIndex);
|
||||
this._selectChild(elementIndex, true);
|
||||
}
|
||||
|
||||
handleMoveDown = id => {
|
||||
const elementIndex = this.getCurrentIndex(id) + 1;
|
||||
this._selectChild(elementIndex);
|
||||
this._selectChild(elementIndex, false);
|
||||
}
|
||||
|
||||
_selectChild (index) {
|
||||
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.node.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -113,18 +113,24 @@ class Notifications extends React.PureComponent {
|
||||
|
||||
handleMoveUp = id => {
|
||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
||||
this._selectChild(elementIndex);
|
||||
this._selectChild(elementIndex, true);
|
||||
}
|
||||
|
||||
handleMoveDown = id => {
|
||||
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
||||
this._selectChild(elementIndex);
|
||||
this._selectChild(elementIndex, false);
|
||||
}
|
||||
|
||||
_selectChild (index) {
|
||||
const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.column.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent {
|
||||
{Component => (
|
||||
<Component
|
||||
preview={video.get('preview_url')}
|
||||
blurhash={video.get('blurhash')}
|
||||
src={video.get('url')}
|
||||
alt={video.get('description')}
|
||||
width={239}
|
||||
|
@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
import MediaGallery from '../../../components/media_gallery';
|
||||
import AttachmentList from '../../../components/attachment_list';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedDate, FormattedNumber } from 'react-intl';
|
||||
import Card from './card';
|
||||
@ -109,14 +108,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||
if (status.get('poll')) {
|
||||
media = <PollContainer pollId={status.get('poll')} />;
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
||||
media = <AttachmentList media={status.get('media_attachments')} />;
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const video = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
<Video
|
||||
preview={video.get('preview_url')}
|
||||
blurhash={video.get('blurhash')}
|
||||
src={video.get('url')}
|
||||
alt={video.get('description')}
|
||||
width={300}
|
||||
|
@ -316,15 +316,15 @@ class Status extends ImmutablePureComponent {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size - 1);
|
||||
this._selectChild(ancestorsIds.size - 1, true);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index);
|
||||
this._selectChild(ancestorsIds.size + index, true);
|
||||
} else {
|
||||
this._selectChild(index - 1);
|
||||
this._selectChild(index - 1, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -333,23 +333,29 @@ class Status extends ImmutablePureComponent {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size + 1);
|
||||
this._selectChild(ancestorsIds.size + 1, false);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index + 2);
|
||||
this._selectChild(ancestorsIds.size + index + 2, false);
|
||||
} else {
|
||||
this._selectChild(index + 1);
|
||||
this._selectChild(index + 1, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_selectChild (index) {
|
||||
const element = this.node.querySelectorAll('.focusable')[index];
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.node;
|
||||
const element = container.querySelectorAll('.focusable')[index];
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -2,11 +2,11 @@ import React from 'react';
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import Video from '../../video';
|
||||
import ExtendedVideoPlayer from '../../../components/extended_video_player';
|
||||
import Video from 'mastodon/features/video';
|
||||
import ExtendedVideoPlayer from 'mastodon/components/extended_video_player';
|
||||
import classNames from 'classnames';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImageLoader from './image_loader';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
@ -24,6 +24,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
index: PropTypes.number.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
@ -72,9 +73,12 @@ class MediaModal extends ImmutablePureComponent {
|
||||
|
||||
componentDidMount () {
|
||||
window.addEventListener('keydown', this.handleKeyDown, false);
|
||||
|
||||
if (this.context.router) {
|
||||
const history = this.context.router.history;
|
||||
|
||||
history.push(history.location.pathname, previewState);
|
||||
|
||||
this.unlistenHistory = history.listen(() => {
|
||||
this.props.onClose();
|
||||
});
|
||||
@ -83,6 +87,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('keydown', this.handleKeyDown);
|
||||
|
||||
if (this.context.router) {
|
||||
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 () {
|
||||
const { media, intl, onClose } = this.props;
|
||||
const { media, status, intl, onClose } = this.props;
|
||||
const { navigationHidden } = this.state;
|
||||
|
||||
const index = this.getIndex();
|
||||
@ -144,6 +156,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||
return (
|
||||
<Video
|
||||
preview={image.get('preview_url')}
|
||||
blurhash={image.get('blurhash')}
|
||||
src={image.get('url')}
|
||||
width={image.get('width')}
|
||||
height={image.get('height')}
|
||||
@ -206,10 +219,19 @@ class MediaModal extends ImmutablePureComponent {
|
||||
{content}
|
||||
</ReactSwipeableViews>
|
||||
</div>
|
||||
|
||||
<div className={navigationClassName}>
|
||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
|
||||
|
||||
{leftNav}
|
||||
{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'>
|
||||
{pagination}
|
||||
</ul>
|
||||
|
@ -1,28 +1,69 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import Video from '../../video';
|
||||
import Video from 'mastodon/features/video';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const previewState = 'previewVideoModal';
|
||||
|
||||
export default class VideoModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
time: PropTypes.number,
|
||||
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 () {
|
||||
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 (
|
||||
<div className='modal-root__modal video-modal'>
|
||||
<div>
|
||||
<Video
|
||||
preview={media.get('preview_url')}
|
||||
blurhash={media.get('blurhash')}
|
||||
src={media.get('url')}
|
||||
startTime={time}
|
||||
onCloseVideo={onClose}
|
||||
link={link}
|
||||
detailed
|
||||
alt={media.get('description')}
|
||||
/>
|
||||
|
@ -367,11 +367,16 @@ class UI extends React.PureComponent {
|
||||
handleHotkeyFocusColumn = e => {
|
||||
const index = (e.key * 1) + 1; // First child is drawer, skip that
|
||||
const column = this.node.querySelector(`.column:nth-child(${index})`);
|
||||
if (!column) return;
|
||||
const container = column.querySelector('.scrollable');
|
||||
|
||||
if (column) {
|
||||
const status = column.querySelector('.focusable');
|
||||
if (container) {
|
||||
const status = container.querySelector('.focusable');
|
||||
|
||||
if (status) {
|
||||
if (container.scrollTop > status.offsetTop) {
|
||||
status.scrollIntoView(true);
|
||||
}
|
||||
status.focus();
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import classNames from 'classnames';
|
||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||
import { displayMedia } from '../../initial_state';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { decode } from 'blurhash';
|
||||
|
||||
const messages = defineMessages({
|
||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||
@ -102,6 +103,8 @@ class Video extends React.PureComponent {
|
||||
inline: PropTypes.bool,
|
||||
cacheWidth: PropTypes.func,
|
||||
intl: PropTypes.object.isRequired,
|
||||
blurhash: PropTypes.string,
|
||||
link: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -139,6 +142,7 @@ class Video extends React.PureComponent {
|
||||
|
||||
setVideoRef = c => {
|
||||
this.video = c;
|
||||
|
||||
if (this.video) {
|
||||
this.setState({ volume: this.video.volume, muted: this.video.muted });
|
||||
}
|
||||
@ -152,6 +156,10 @@ class Video extends React.PureComponent {
|
||||
this.volume = c;
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
}
|
||||
|
||||
handleClickRoot = e => e.stopPropagation();
|
||||
|
||||
handlePlay = () => {
|
||||
@ -170,7 +178,6 @@ class Video extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleVolumeMouseDown = e => {
|
||||
|
||||
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
||||
@ -190,7 +197,6 @@ class Video extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleMouseVolSlide = throttle(e => {
|
||||
|
||||
const rect = this.volume.getBoundingClientRect();
|
||||
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('mozfullscreenchange', this.handleFullscreenChange, true);
|
||||
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
||||
|
||||
if (this.props.blurhash) {
|
||||
this._decode();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
@ -270,6 +280,24 @@ class Video extends React.PureComponent {
|
||||
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 = () => {
|
||||
this.setState({ fullscreen: isFullscreen() });
|
||||
}
|
||||
@ -314,6 +342,7 @@ class Video extends React.PureComponent {
|
||||
|
||||
handleOpenVideo = () => {
|
||||
const { src, preview, width, height, alt } = this.props;
|
||||
|
||||
const media = fromJS({
|
||||
type: 'video',
|
||||
url: src,
|
||||
@ -333,7 +362,7 @@ class Video extends React.PureComponent {
|
||||
}
|
||||
|
||||
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 progress = (currentTime / duration) * 100;
|
||||
|
||||
@ -351,6 +380,7 @@ class Video extends React.PureComponent {
|
||||
}
|
||||
|
||||
let preload;
|
||||
|
||||
if (startTime || fullscreen || dragging) {
|
||||
preload = 'auto';
|
||||
} else if (detailed) {
|
||||
@ -360,6 +390,7 @@ class Video extends React.PureComponent {
|
||||
}
|
||||
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
@ -377,7 +408,9 @@ class Video extends React.PureComponent {
|
||||
onClick={this.handleClickRoot}
|
||||
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}
|
||||
src={src}
|
||||
poster={preview}
|
||||
@ -397,12 +430,13 @@ class Video extends React.PureComponent {
|
||||
onLoadedData={this.handleLoadedData}
|
||||
onProgress={this.handleProgress}
|
||||
onVolumeChange={this.handleVolumeChange}
|
||||
/>
|
||||
/>}
|
||||
|
||||
<button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
|
||||
<span className='video-player__spoiler__title'>{warning}</span>
|
||||
<span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
||||
<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'>
|
||||
<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>
|
||||
|
||||
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
||||
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
|
||||
<span
|
||||
@ -429,17 +464,19 @@ class Video extends React.PureComponent {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(detailed || fullscreen) &&
|
||||
{(detailed || fullscreen) && (
|
||||
<span>
|
||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>{formatTime(duration)}</span>
|
||||
</span>
|
||||
}
|
||||
)}
|
||||
|
||||
{link && <span className='video-player__link'>{link}</span>}
|
||||
</div>
|
||||
|
||||
<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>}
|
||||
{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>
|
||||
|
@ -243,7 +243,7 @@
|
||||
"navigation_bar.pins": "Ամրացված թթեր",
|
||||
"navigation_bar.preferences": "Նախապատվություններ",
|
||||
"navigation_bar.public_timeline": "Դաշնային հոսք",
|
||||
"navigation_bar.security": "Security",
|
||||
"navigation_bar.security": "Անվտանգություն",
|
||||
"notification.favourite": "{name} հավանեց թութդ",
|
||||
"notification.follow": "{name} սկսեց հետեւել քեզ",
|
||||
"notification.mention": "{name} նշեց քեզ",
|
||||
@ -309,7 +309,7 @@
|
||||
"search_results.accounts": "People",
|
||||
"search_results.hashtags": "Hashtags",
|
||||
"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_status": "Open this status in the moderation interface",
|
||||
"status.block": "Արգելափակել @{name}֊ին",
|
||||
|
@ -264,6 +264,16 @@
|
||||
.compose-form {
|
||||
padding: 10px;
|
||||
|
||||
&__sensitive-button {
|
||||
padding: 10px;
|
||||
padding-top: 0;
|
||||
|
||||
.icon-button {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.compose-form__warning {
|
||||
color: $inverted-text-color;
|
||||
margin-bottom: 10px;
|
||||
@ -2412,7 +2422,7 @@ a.account__display-name {
|
||||
|
||||
& > div {
|
||||
background: rgba($base-shadow-color, 0.6);
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 12px 9px;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
@ -2423,19 +2433,18 @@ a.account__display-name {
|
||||
button,
|
||||
a {
|
||||
display: inline;
|
||||
color: $primary-text-color;
|
||||
color: $secondary-text-color;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0 5px;
|
||||
padding: 0 8px;
|
||||
text-decoration: none;
|
||||
opacity: 0.6;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2932,15 +2941,49 @@ a.status-card.compact:hover {
|
||||
}
|
||||
|
||||
.spoiler-button {
|
||||
display: none;
|
||||
left: 4px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
|
||||
top: 4px;
|
||||
z-index: 100;
|
||||
|
||||
&.spoiler-button--visible {
|
||||
&--minified {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3728,6 +3771,31 @@ a.status-card.compact:hover {
|
||||
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 {
|
||||
display: inline-block;
|
||||
}
|
||||
@ -4200,6 +4268,7 @@ a.status-card.compact:hover {
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.1s ease;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.media-gallery__gifv {
|
||||
@ -4313,6 +4382,8 @@ a.status-card.compact:hover {
|
||||
text-decoration: none;
|
||||
color: $secondary-text-color;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&,
|
||||
img {
|
||||
@ -4325,6 +4396,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 {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
@ -4620,6 +4706,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 {
|
||||
cursor: pointer;
|
||||
height: 24px;
|
||||
@ -4712,62 +4815,18 @@ a.status-card.compact:hover {
|
||||
|
||||
.account-gallery__container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 2px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.account-gallery__item {
|
||||
flex-grow: 1;
|
||||
width: 50%;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 2px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: $base-overlay-background;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: absolute;
|
||||
color: $darker-text-color;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
outline: 0;
|
||||
color: $secondary-text-color;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba($base-overlay-background, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icons {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification__filter-bar,
|
||||
|
@ -533,6 +533,17 @@ code {
|
||||
color: $error-value-color;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
color: $darker-text-color;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $primary-text-color;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
@ -194,7 +194,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
next if attachment['url'].blank?
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
def supported_blurhash?(blurhash)
|
||||
components = blurhash.blank? ? nil : Blurhash.components(blurhash)
|
||||
components.present? && components.none? { |comp| comp > 5 }
|
||||
end
|
||||
|
||||
def skip_download?
|
||||
return @skip_download if defined?(@skip_download)
|
||||
@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' },
|
||||
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
|
||||
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
||||
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||
}.freeze
|
||||
|
||||
def self.default_key_transform
|
||||
|
@ -6,6 +6,7 @@ module LdapAuthenticable
|
||||
def ldap_setup(_attributes)
|
||||
self.confirmed_at = Time.now.utc
|
||||
self.admin = false
|
||||
self.external = true
|
||||
|
||||
save!
|
||||
end
|
||||
|
@ -66,6 +66,7 @@ module Omniauthable
|
||||
email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
|
||||
password: Devise.friendly_token[0, 20],
|
||||
agreement: true,
|
||||
external: true,
|
||||
account_attributes: {
|
||||
username: ensure_unique_username(auth.uid),
|
||||
display_name: display_name,
|
||||
|
@ -34,6 +34,7 @@ module PamAuthenticable
|
||||
self.confirmed_at = Time.now.utc
|
||||
self.admin = false
|
||||
self.account = account
|
||||
self.external = true
|
||||
|
||||
account.destroy! unless save
|
||||
end
|
||||
|
@ -29,4 +29,11 @@ class DomainBlock < ApplicationRecord
|
||||
def self.blocked?(domain)
|
||||
where(domain: domain, severity: :suspend).exists?
|
||||
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
|
||||
|
@ -18,6 +18,7 @@
|
||||
# account_id :bigint(8)
|
||||
# description :text
|
||||
# scheduled_status_id :bigint(8)
|
||||
# blurhash :string
|
||||
#
|
||||
|
||||
class MediaAttachment < ApplicationRecord
|
||||
@ -34,6 +35,11 @@ class MediaAttachment < ApplicationRecord
|
||||
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
|
||||
|
||||
BLURHASH_OPTIONS = {
|
||||
x_comp: 4,
|
||||
y_comp: 4,
|
||||
}.freeze
|
||||
|
||||
IMAGE_STYLES = {
|
||||
original: {
|
||||
pixels: 1_638_400, # 1280x1280px
|
||||
@ -43,6 +49,7 @@ class MediaAttachment < ApplicationRecord
|
||||
small: {
|
||||
pixels: 160_000, # 400x400px
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
blurhash: BLURHASH_OPTIONS,
|
||||
},
|
||||
}.freeze
|
||||
|
||||
@ -71,6 +78,8 @@ class MediaAttachment < ApplicationRecord
|
||||
},
|
||||
format: 'png',
|
||||
time: 0,
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
blurhash: BLURHASH_OPTIONS,
|
||||
},
|
||||
}.freeze
|
||||
|
||||
@ -186,13 +195,13 @@ class MediaAttachment < ApplicationRecord
|
||||
|
||||
def file_processors(f)
|
||||
if f.file_content_type == 'image/gif'
|
||||
[:gif_transcoder]
|
||||
[:gif_transcoder, :blurhash_transcoder]
|
||||
elsif VIDEO_MIME_TYPES.include? f.file_content_type
|
||||
[:video_transcoder]
|
||||
[:video_transcoder, :blurhash_transcoder]
|
||||
elsif AUDIO_MIME_TYPES.include? f.file_content_type
|
||||
[:audio_transcoder]
|
||||
else
|
||||
[:lazy_thumbnail]
|
||||
[:lazy_thumbnail, :blurhash_transcoder]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -78,7 +78,7 @@ class User < ApplicationRecord
|
||||
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
|
||||
|
||||
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 :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
|
||||
|
||||
attr_reader :invite_code
|
||||
attr_writer :external
|
||||
|
||||
def confirmed?
|
||||
confirmed_at.present?
|
||||
end
|
||||
|
||||
def invited?
|
||||
invite_id.present?
|
||||
invite_id.present? && invite.valid_for_use?
|
||||
end
|
||||
|
||||
def disable!
|
||||
@ -273,13 +274,17 @@ class User < ApplicationRecord
|
||||
private
|
||||
|
||||
def set_approved
|
||||
self.approved = open_registrations? || invited?
|
||||
self.approved = open_registrations? || invited? || external?
|
||||
end
|
||||
|
||||
def open_registrations?
|
||||
Setting.registrations_mode == 'open'
|
||||
end
|
||||
|
||||
def external?
|
||||
!!@external
|
||||
end
|
||||
|
||||
def sanitize_languages
|
||||
return if chosen_languages.nil?
|
||||
chosen_languages.reject!(&:blank?)
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||
context_extensions :atom_uri, :conversation, :sensitive,
|
||||
:hashtag, :emoji, :focal_point
|
||||
:hashtag, :emoji, :focal_point, :blurhash
|
||||
|
||||
attributes :id, :type, :summary,
|
||||
:in_reply_to, :published, :url,
|
||||
@ -153,7 +153,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||
class MediaAttachmentSerializer < ActivityPub::Serializer
|
||||
include RoutingHelper
|
||||
|
||||
attributes :type, :media_type, :url, :name
|
||||
attributes :type, :media_type, :url, :name, :blurhash
|
||||
attribute :focal_point, if: :focal_point?
|
||||
|
||||
def type
|
||||
|
@ -5,7 +5,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
|
||||
|
||||
attributes :id, :type, :url, :preview_url,
|
||||
:remote_url, :text_url, :meta,
|
||||
:description
|
||||
:description, :blurhash
|
||||
|
||||
def id
|
||||
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(target_account, account) if target_account.following?(account)
|
||||
RejectFollowService.new.call(account, target_account) if target_account.requested?(account)
|
||||
|
||||
block = account.block!(target_account)
|
||||
|
||||
|
@ -2,7 +2,10 @@
|
||||
|
||||
class BlacklistedEmailValidator < ActiveModel::Validator
|
||||
def validate(user)
|
||||
return if user.invited?
|
||||
|
||||
@email = user.email
|
||||
|
||||
user.errors.add(:email, I18n.t('users.invalid_email')) if blocked_email?
|
||||
end
|
||||
|
||||
|
@ -28,7 +28,7 @@
|
||||
- elsif !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.video?
|
||||
- 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 }
|
||||
- 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
|
||||
|
@ -33,7 +33,7 @@
|
||||
- elsif !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.video?
|
||||
- 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 }
|
||||
- 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
|
||||
|
@ -7,5 +7,7 @@ class ActivityPub::ProcessingWorker
|
||||
|
||||
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)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.debug "Error processing incoming ActivityPub object: #{e}"
|
||||
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']
|
||||
Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
|
||||
end
|
||||
|
@ -269,6 +269,7 @@ co:
|
||||
created_msg: U blucchime di u duminiu hè attivu
|
||||
destroyed_msg: U blucchime di u duminiu ùn hè più attivu
|
||||
domain: Duminiu
|
||||
existing_domain_block_html: Avete digià impostu limite più strette nant'à %{name}, duvete <a href="%{unblock_url}">sbluccallu</a> primu.
|
||||
new:
|
||||
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.
|
||||
|
@ -270,6 +270,7 @@ en:
|
||||
created_msg: Domain block is now being processed
|
||||
destroyed_msg: Domain block has been undone
|
||||
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:
|
||||
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.
|
||||
|
@ -260,10 +260,10 @@ fr:
|
||||
title: Nouveau blocage de domaine
|
||||
reject_media: Fichiers média rejetés
|
||||
reject_media_hint: Supprime localement les fichiers média stockés et refuse d’en télécharger ultérieurement. Ne concerne pas les suspensions
|
||||
reject_reports: Rapports de rejet
|
||||
reject_reports_hint: Ignorez tous les rapports provenant de ce domaine. Sans objet pour les suspensions
|
||||
reject_reports: Rejeter les signalements
|
||||
reject_reports_hint: Ignorez tous les signalements provenant de ce domaine. Ne concerne pas les suspensions
|
||||
rejecting_media: rejet des fichiers multimédia
|
||||
rejecting_reports: rejet de rapports
|
||||
rejecting_reports: rejet des signalements
|
||||
severity:
|
||||
silence: silencié
|
||||
suspend: suspendu
|
||||
|
@ -527,16 +527,17 @@ sk:
|
||||
login: Prihlás sa
|
||||
logout: Odhlás sa
|
||||
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>.
|
||||
or_log_in_with: Alebo prihlásiť z
|
||||
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ás s
|
||||
providers:
|
||||
cas: CAS
|
||||
saml: SAML
|
||||
register: Zaregistruj sa
|
||||
resend_confirmation: Poslať potvrdzujúce pokyny znovu
|
||||
resend_confirmation: Zašli potvrdzujúce pokyny znovu
|
||||
reset_password: Obnov heslo
|
||||
security: Zabezpečenie
|
||||
set_new_password: Nastav nové heslo
|
||||
trouble_logging_in: Problém s prihlásením?
|
||||
authorize_follow:
|
||||
already_following: Tento účet už následuješ
|
||||
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.
|
||||
|
||||
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
|
||||
enable_extension "plpgsql"
|
||||
@ -373,6 +373,7 @@ ActiveRecord::Schema.define(version: 2019_04_09_054914) do
|
||||
t.bigint "account_id"
|
||||
t.text "description"
|
||||
t.bigint "scheduled_status_id"
|
||||
t.string "blurhash"
|
||||
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 ["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/statuses_cli'
|
||||
require_relative 'mastodon/domains_cli'
|
||||
require_relative 'mastodon/cache_cli'
|
||||
require_relative 'mastodon/version'
|
||||
|
||||
module Mastodon
|
||||
@ -41,6 +42,9 @@ module Mastodon
|
||||
desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
|
||||
subcommand 'domains', Mastodon::DomainsCLI
|
||||
|
||||
desc 'cache SUBCOMMAND ...ARGS', 'Manage cache'
|
||||
subcommand 'cache', Mastodon::CacheCLI
|
||||
|
||||
option :dry_run, type: :boolean
|
||||
desc 'self-destruct', 'Erase the server from the federation'
|
||||
long_desc <<~LONG_DESC
|
||||
|
@ -73,7 +73,7 @@ module Mastodon
|
||||
def create(username)
|
||||
account = Account.new(username: username)
|
||||
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]
|
||||
account = Account.find_local(username) || Account.new(username: username)
|
||||
@ -115,6 +115,7 @@ module Mastodon
|
||||
option :enable, type: :boolean
|
||||
option :disable, type: :boolean
|
||||
option :disable_2fa, type: :boolean
|
||||
option :approve, type: :boolean
|
||||
desc 'modify USERNAME', 'Modify a user'
|
||||
long_desc <<-LONG_DESC
|
||||
Modify a user account.
|
||||
@ -128,6 +129,9 @@ module Mastodon
|
||||
With the --disable option, lock the user out of their account. The
|
||||
--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
|
||||
requirement for the user can be removed.
|
||||
LONG_DESC
|
||||
@ -147,6 +151,7 @@ module Mastodon
|
||||
user.email = options[:email] if options[:email]
|
||||
user.disabled = false if options[:enable]
|
||||
user.disabled = true if options[:disable]
|
||||
user.approved = true if options[:approve]
|
||||
user.otp_required_for_login = false if options[:disable_2fa]
|
||||
user.confirm if options[:confirm]
|
||||
|
||||
|
19
lib/mastodon/cache_cli.rb
Normal file
19
lib/mastodon/cache_cli.rb
Normal file
@ -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
|
||||
|
||||
def patch
|
||||
0
|
||||
1
|
||||
end
|
||||
|
||||
def pre
|
||||
|
16
lib/paperclip/blurhash_transcoder.rb
Normal file
16
lib/paperclip/blurhash_transcoder.rb
Normal file
@ -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-transform-react-remove-prop-types": "^0.4.24",
|
||||
"babel-runtime": "^6.26.0",
|
||||
"blurhash": "^1.0.0",
|
||||
"classnames": "^2.2.5",
|
||||
"compression-webpack-plugin": "^2.0.0",
|
||||
"cross-env": "^5.1.4",
|
||||
|
@ -2,3 +2,4 @@
|
||||
|
||||
User-agent: *
|
||||
Disallow: /media_proxy/
|
||||
Disallow: /interact/
|
||||
|
@ -37,7 +37,7 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
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(response).to render_template :new
|
||||
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
|
||||
|
||||
describe 'DELETE #destroy' do
|
||||
|
@ -107,6 +107,89 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
|
||||
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
|
||||
Fabricate(:user, account: Fabricate(:account, username: 'test'))
|
||||
subject
|
||||
|
@ -36,4 +36,35 @@ RSpec.describe DomainBlock, type: :model do
|
||||
expect(DomainBlock.blocked?('domain')).to eq false
|
||||
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
|
||||
|
@ -8,6 +8,7 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do
|
||||
let(:errors) { double(add: nil) }
|
||||
|
||||
before do
|
||||
allow(user).to receive(:invited?) { false }
|
||||
allow_any_instance_of(described_class).to receive(:blocked_email?) { blocked_email }
|
||||
described_class.new.validate(user)
|
||||
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"
|
||||
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:
|
||||
version "4.11.8"
|
||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
|
||||
|
Loading…
Reference in New Issue
Block a user