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

Conflicts:
- app/models/media_attachment.rb
This commit is contained in:
Thibaut Girka
2019-05-04 16:37:26 +02:00
67 changed files with 896 additions and 288 deletions

View File

@@ -13,13 +13,25 @@ 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 @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
log_action :create, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else
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
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else
render :new
end
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }){

View File

@@ -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}`, {

View File

@@ -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,12 +154,20 @@ 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']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
@@ -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;
const size = media.take(4).size;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
if (this.isStandaloneEligible()) {
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} visible={visible} />);
}
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>
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>
);
} else {
const size = media.take(4).size;
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
}
}
return (
<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}

View File

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

View File

@@ -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();
}
}

View File

@@ -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 x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const style = {};
const { attachment, displayWidth } = this.props;
const { visible, loaded } = this.state;
let label, icon;
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
const height = width;
const status = attachment.get('status');
if (media.get('type') === 'gifv') {
label = <span className='media-gallery__gifv__label'>GIF</span>;
}
let thumbnail = '';
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>
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;
thumbnail = (
<img
src={attachment.get('preview_url')}
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
);
} else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
const autoPlay = !isIOS() && autoPlayGif;
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video
className='media-gallery__item-gifv-thumbnail'
aria-label={attachment.get('description')}
title={attachment.get('description')}
role='application'
src={attachment.get('url')}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
autoPlay={autoPlay}
loop
muted
/>
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
}
return (
<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>
);
}

View File

@@ -2,24 +2,25 @@ 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']),
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
});
class LoadMoreMedia extends ImmutablePureComponent {
@@ -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>

View File

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

View File

@@ -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>
);
}

View File

@@ -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,32 +34,14 @@ 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>
);
}}
</Motion>
<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>
);
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

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

View File

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

View File

@@ -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();
}
}

View File

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

View File

@@ -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')}
/>

View File

@@ -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();
}
}

View File

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

View File

@@ -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}֊ին",

View File

@@ -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);
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;
}
border-radius: 4px;
overflow: hidden;
margin: 2px;
}
.notification__filter-bar,

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ module LdapAuthenticable
def ldap_setup(_attributes)
self.confirmed_at = Time.now.utc
self.admin = false
self.external = true
save!
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -13,7 +16,7 @@ class BlacklistedEmailValidator < ActiveModel::Validator
end
def on_blacklist?
return true if EmailDomainBlock.block?(@email)
return true if EmailDomainBlock.block?(@email)
return false if Rails.configuration.x.email_domains_blacklist.blank?
domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')

View File

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

View File

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

View File

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