Merge remote-tracking branch 'upstream/master'

This commit is contained in:
beatrix-bitrot
2017-06-27 20:46:13 +00:00
91 changed files with 1274 additions and 751 deletions

View File

@ -17,6 +17,9 @@ class Api::V1::ReportsController < Api::BaseController
status_ids: reported_status_ids,
comment: report_params[:comment]
)
User.admins.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later }
render :show
end

View File

@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
include UserTrackingConcern
helper_method :current_account
helper_method :current_session
helper_method :single_user_mode?
rescue_from ActionController::RoutingError, with: :not_found
@ -68,6 +69,10 @@ class ApplicationController < ActionController::Base
@current_account ||= current_user.try(:account)
end
def current_session
@current_session ||= SessionActivation.find_by(session_id: session['auth_id'])
end
def cache_collection(raw, klass)
return raw unless klass.respond_to?(:with_includes)

View File

@ -5,6 +5,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update]
def destroy
not_found
@ -41,4 +42,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def determine_layout
%w(edit update).include?(action_name) ? 'admin' : 'auth'
end
def set_sessions
@sessions = current_user.session_activations
end
end

View File

@ -5,7 +5,7 @@ class HomeController < ApplicationController
def index
@body_classes = 'app-body'
@token = find_or_create_access_token.token
@token = current_session.token
@web_settings = Web::Setting.find_by(user: current_user)&.data || {}
@admin = Account.find_local(Setting.site_contact_username)
@streaming_api_base_url = Rails.configuration.x.streaming_api_base_url
@ -16,14 +16,4 @@ class HomeController < ApplicationController
def authenticate_user!
redirect_to(single_user_mode? ? account_path(Account.first) : about_path) unless user_signed_in?
end
def find_or_create_access_token
Doorkeeper::AccessToken.find_or_create_for(
Doorkeeper::Application.where(superapp: true).first,
current_user.id,
Doorkeeper::OAuth::Scopes.from_string('read write follow'),
Doorkeeper.configuration.access_token_expires_in,
Doorkeeper.configuration.refresh_token_enabled?
)
end
end

View File

@ -7,7 +7,9 @@ module Settings
before_action :authenticate_user!
before_action :verify_otp_required, only: [:create]
def show; end
def show
@confirmation = Form::TwoFactorConfirmation.new
end
def create
current_user.otp_secret = User.generate_otp_secret(32)
@ -16,13 +18,23 @@ module Settings
end
def destroy
current_user.otp_required_for_login = false
current_user.save!
redirect_to settings_two_factor_authentication_path
if current_user.validate_and_consume_otp!(confirmation_params[:code])
current_user.otp_required_for_login = false
current_user.save!
redirect_to settings_two_factor_authentication_path
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
@confirmation = Form::TwoFactorConfirmation.new
render :show
end
end
private
def confirmation_params
params.require(:form_two_factor_confirmation).permit(:code)
end
def verify_otp_required
redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
end

View File

@ -41,4 +41,16 @@ module SettingsHelper
def hash_to_object(hash)
HashObject.new(hash)
end
def session_device_icon(session)
device = session.detection.device
if device.mobile?
'mobile'
elsif device.tablet?
'tablet'
else
'desktop'
end
end
end

View File

@ -1,4 +1,5 @@
import api from '../api';
import { openModal, closeModal } from './modal';
export const REPORT_INIT = 'REPORT_INIT';
export const REPORT_CANCEL = 'REPORT_CANCEL';
@ -11,10 +12,14 @@ export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
export function initReport(account, status) {
return {
type: REPORT_INIT,
account,
status,
return dispatch => {
dispatch({
type: REPORT_INIT,
account,
status,
});
dispatch(openModal('REPORT'));
};
};
@ -40,7 +45,10 @@ export function submitReport() {
account_id: getState().getIn(['reports', 'new', 'account_id']),
status_ids: getState().getIn(['reports', 'new', 'status_ids']),
comment: getState().getIn(['reports', 'new', 'comment']),
}).then(response => dispatch(submitReportSuccess(response.data))).catch(error => dispatch(submitReportFail(error)));
}).then(response => {
dispatch(closeModal());
dispatch(submitReportSuccess(response.data));
}).catch(error => dispatch(submitReportFail(error)));
};
};

View File

@ -1,50 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
export default class ColumnCollapsable extends React.PureComponent {
static propTypes = {
icon: PropTypes.string.isRequired,
title: PropTypes.string,
fullHeight: PropTypes.number.isRequired,
children: PropTypes.node,
onCollapse: PropTypes.func,
};
state = {
collapsed: true,
animating: false,
};
handleToggleCollapsed = () => {
const currentState = this.state.collapsed;
this.setState({ collapsed: !currentState, animating: true });
if (!currentState && this.props.onCollapse) {
this.props.onCollapse();
}
}
handleTransitionEnd = () => {
this.setState({ animating: false });
}
render () {
const { icon, title, fullHeight, children } = this.props;
const { collapsed, animating } = this.state;
return (
<div className={`column-collapsable ${collapsed ? 'collapsed' : ''}`} onTransitionEnd={this.handleTransitionEnd}>
<div role='button' tabIndex='0' title={`${title}`} className='column-collapsable__button column-icon' onClick={this.handleToggleCollapsed}>
<i className={`fa fa-${icon}`} />
</div>
<div className='column-collapsable__content' style={{ height: `${fullHeight}px` }}>
{(!collapsed || animating) && children}
</div>
</div>
);
}
}

View File

@ -132,7 +132,7 @@ export default class ColumnHeader extends React.PureComponent {
</div>
<div className={collapsibleClassName} onTransitionEnd={this.handleTransitionEnd}>
<div>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}
</div>
</div>

View File

@ -85,14 +85,24 @@ class Item extends React.PureComponent {
let thumbnail = '';
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 srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
const sizes = `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`;
thumbnail = (
<a // eslint-disable-line jsx-a11y/anchor-has-content
<a
className='media-gallery__item-thumbnail'
href={attachment.get('remote_url') || attachment.get('url')}
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
style={{ backgroundImage: `url(${attachment.get('preview_url')})` }}
/>
>
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' />
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && this.props.autoPlayGif;

View File

@ -25,7 +25,7 @@ export default class Permalink extends React.PureComponent {
const { href, children, className, ...other } = this.props;
return (
<a href={href} onClick={this.handleClick} {...other} className={'permalink ' + className}>
<a href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>
{children}
</a>
);

View File

@ -149,7 +149,7 @@ class StatusUnextended extends ImmutablePureComponent {
saveHeight = () => {
if (this.node && this.node.children.length !== 0) {
this.height = this.node.clientHeight;
this.height = this.node.getBoundingClientRect().height;
}
}

View File

@ -88,7 +88,6 @@ export default class StatusActionBar extends ImmutablePureComponent {
handleReport = () => {
this.props.onReport(this.props.status);
this.context.router.history.push('/report');
}
handleConversationMuteClick = () => {

View File

@ -38,7 +38,6 @@ export default class Header extends ImmutablePureComponent {
handleReport = () => {
this.props.onReport(this.props.account);
this.context.router.history.push('/report');
}
handleMute = () => {

View File

@ -67,6 +67,12 @@ export default class ComposeForm extends ImmutablePureComponent {
}
handleSubmit = () => {
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
this.props.onChange(this.autosuggestTextarea.textarea.value);
}
this.props.onSubmit();
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
import Column from '../ui/components/column';
@ -14,7 +15,9 @@ const messages = defineMessages({
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
me: state.getIn(['meta', 'me']),
});
@connect(mapStateToProps)
@ -23,8 +26,10 @@ export default class Favourites extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
loaded: PropTypes.bool,
intl: PropTypes.object.isRequired,
me: PropTypes.number.isRequired,
};
componentWillMount () {

View File

@ -13,6 +13,7 @@ import ColumnSettingsContainer from './containers/column_settings_container';
import { createSelector } from 'reselect';
import Immutable from 'immutable';
import LoadMore from '../../components/load_more';
import { debounce } from 'lodash';
const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@ -50,19 +51,27 @@ export default class Notifications extends React.PureComponent {
trackScroll: true,
};
dispatchExpandNotifications = debounce(() => {
this.props.dispatch(expandNotifications());
}, 300, { leading: true });
dispatchScrollToTop = debounce((top) => {
this.props.dispatch(scrollTopNotifications(top));
}, 100);
handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
this._oldScrollPosition = scrollHeight - scrollTop;
if (250 > offset && !this.props.isLoading) {
if (this.props.hasMore) {
this.props.dispatch(expandNotifications());
}
} else if (scrollTop < 100) {
this.props.dispatch(scrollTopNotifications(true));
if (250 > offset && this.props.hasMore && !this.props.isLoading) {
this.dispatchExpandNotifications();
}
if (scrollTop < 100) {
this.dispatchScrollToTop(true);
} else {
this.props.dispatch(scrollTopNotifications(false));
this.dispatchScrollToTop(false);
}
}
@ -74,7 +83,7 @@ export default class Notifications extends React.PureComponent {
handleLoadMore = (e) => {
e.preventDefault();
this.props.dispatch(expandNotifications());
this.dispatchExpandNotifications();
}
handlePin = () => {

View File

@ -56,7 +56,6 @@ export default class ActionBar extends React.PureComponent {
handleReport = () => {
this.props.onReport(this.props.status);
this.context.router.history.push('/report');
}
render () {

View File

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default class ImageLoader extends React.PureComponent {
@ -20,46 +21,121 @@ export default class ImageLoader extends React.PureComponent {
error: false,
}
componentWillMount() {
this._loadImage(this.props.src);
removers = [];
get canvasContext() {
if (!this.canvas) {
return null;
}
this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
return this._canvasContext;
}
componentWillReceiveProps(props) {
this._loadImage(props.src);
componentDidMount () {
this.loadImage(this.props);
}
_loadImage(src) {
componentWillReceiveProps (nextProps) {
if (this.props.src !== nextProps.src) {
this.loadImage(nextProps);
}
}
loadImage (props) {
this.removeEventListeners();
this.setState({ loading: true, error: false });
Promise.all([
this.loadPreviewCanvas(props),
this.loadOriginalImage(props),
])
.then(() => {
this.setState({ loading: false, error: false });
this.clearPreviewCanvas();
})
.catch(() => this.setState({ loading: false, error: true }));
}
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
this.canvasContext.drawImage(image, 0, 0, width, height);
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = previewSrc;
this.removers.push(removeEventListeners);
})
image.onerror = () => this.setState({ loading: false, error: true });
image.onload = () => this.setState({ loading: false, error: false });
image.src = src;
this.setState({ loading: true });
clearPreviewCanvas () {
const { width, height } = this.canvas;
this.canvasContext.clearRect(0, 0, width, height);
}
render() {
const { alt, src, previewSrc, width, height } = this.props;
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = src;
this.removers.push(removeEventListeners);
});
removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
setCanvasRef = c => {
this.canvas = c;
}
render () {
const { alt, src, width, height } = this.props;
const { loading } = this.state;
const className = classNames('image-loader', {
'image-loader--loading': loading,
});
return (
<div className='image-loader'>
<img
alt={alt}
className='image-loader__img'
src={src}
<div className={className}>
<canvas
className='image-loader__preview-canvas'
width={width}
height={height}
ref={this.setCanvasRef}
/>
{loading &&
{!loading && (
<img
alt=''
src={previewSrc}
className='image-loader__preview-img'
alt={alt}
className='image-loader__img'
src={src}
width={width}
height={height}
/>
}
)}
</div>
);
}

View File

@ -5,6 +5,7 @@ import OnboardingModal from './onboarding_modal';
import VideoModal from './video_modal';
import BoostModal from './boost_modal';
import ConfirmationModal from './confirmation_modal';
import ReportModal from './report_modal';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
@ -14,6 +15,7 @@ const MODAL_COMPONENTS = {
'VIDEO': VideoModal,
'BOOST': BoostModal,
'CONFIRM': ConfirmationModal,
'REPORT': ReportModal,
};
export default class ModalRoot extends React.PureComponent {

View File

@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ReactSwipeable from 'react-swipeable';
import classNames from 'classnames';
import Permalink from '../../../components/permalink';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
@ -274,7 +275,7 @@ export default class OnboardingModal extends React.PureComponent {
<div className='modal-root__modal onboarding-modal'>
<TransitionMotion styles={styles}>
{interpolatedStyles => (
<div className='onboarding-modal__pager'>
<ReactSwipeable onSwipedRight={this.handlePrev} onSwipedLeft={this.handleNext} className='onboarding-modal__pager'>
{interpolatedStyles.map(({ key, data, style }, i) => {
const className = classNames('onboarding-modal__page__wrapper', {
'onboarding-modal__page__wrapper--active': i === currentIndex,
@ -283,7 +284,7 @@ export default class OnboardingModal extends React.PureComponent {
<div key={key} style={style} className={className}>{data}</div>
);
})}
</div>
</ReactSwipeable>
)}
</TransitionMotion>

View File

@ -1,19 +1,17 @@
import React from 'react';
import { connect } from 'react-redux';
import { changeReportComment, submitReport } from '../../actions/reports';
import { refreshAccountTimeline } from '../../actions/timelines';
import { changeReportComment, submitReport } from '../../../actions/reports';
import { refreshAccountTimeline } from '../../../actions/timelines';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../ui/components/column';
import Button from '../../components/button';
import { makeGetAccount } from '../../selectors';
import { makeGetAccount } from '../../../selectors';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import StatusCheckBox from './containers/status_check_box_container';
import StatusCheckBox from '../../report/containers/status_check_box_container';
import Immutable from 'immutable';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Button from '../../../components/button';
const messages = defineMessages({
heading: { id: 'report.heading', defaultMessage: 'New report' },
placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
submit: { id: 'report.submit', defaultMessage: 'Submit' },
});
@ -37,11 +35,7 @@ const makeMapStateToProps = () => {
@connect(makeMapStateToProps)
@injectIntl
export default class Report extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
export default class ReportModal extends ImmutablePureComponent {
static propTypes = {
isSubmitting: PropTypes.bool,
@ -52,17 +46,15 @@ export default class Report extends React.PureComponent {
intl: PropTypes.object.isRequired,
};
componentWillMount () {
if (!this.props.account) {
this.context.router.history.replace('/');
}
handleCommentChange = (e) => {
this.props.dispatch(changeReportComment(e.target.value));
}
handleSubmit = () => {
this.props.dispatch(submitReport());
}
componentDidMount () {
if (!this.props.account) {
return;
}
this.props.dispatch(refreshAccountTimeline(this.props.account.get('id')));
}
@ -72,15 +64,6 @@ export default class Report extends React.PureComponent {
}
}
handleCommentChange = (e) => {
this.props.dispatch(changeReportComment(e.target.value));
}
handleSubmit = () => {
this.props.dispatch(submitReport());
this.context.router.history.replace('/');
}
render () {
const { account, comment, intl, statusIds, isSubmitting } = this.props;
@ -89,36 +72,33 @@ export default class Report extends React.PureComponent {
}
return (
<Column heading={intl.formatMessage(messages.heading)} icon='flag'>
<ColumnBackButtonSlim />
<div className='modal-root__modal report-modal'>
<div className='report-modal__target'>
<FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
</div>
<div className='report scrollable'>
<div className='report__target'>
<FormattedMessage id='report.target' defaultMessage='Reporting' />
<strong>{account.get('acct')}</strong>
</div>
<div className='scrollable report__statuses'>
<div className='report-modal__container'>
<div className='report-modal__statuses'>
<div>
{statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
</div>
</div>
<div className='report__textarea-wrapper'>
<div className='report-modal__comment'>
<textarea
className='report__textarea'
className='setting-text light'
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={this.handleCommentChange}
disabled={isSubmitting}
/>
<div className='report__submit'>
<div className='report__submit-button'><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div>
</div>
</div>
</div>
</Column>
<div className='report-modal__action-bar'>
<Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} />
</div>
</div>
);
}

View File

@ -15,7 +15,6 @@ import { refreshHomeTimeline } from '../../actions/timelines';
import { refreshNotifications } from '../../actions/notifications';
import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container';
import Status from '../../features/status';
import GettingStarted from '../../features/getting_started';
import PublicTimeline from '../../features/public_timeline';
@ -35,7 +34,6 @@ import GenericNotFound from '../../features/generic_not_found';
import FavouritedStatuses from '../../features/favourited_statuses';
import Blocks from '../../features/blocks';
import Mutes from '../../features/mutes';
import Report from '../../features/report';
// Small wrapper to pass multiColumn to the route components
const WrappedSwitch = ({ multiColumn, children }) => (
@ -222,7 +220,6 @@ export default class UI extends React.PureComponent {
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/report' component={Report} content={children} />
<WrappedRoute component={GenericNotFound} content={children} />
</WrappedSwitch>

View File

@ -1151,6 +1151,23 @@
],
"path": "app/javascript/mastodon/features/ui/components/onboarding_modal.json"
},
{
"descriptors": [
{
"defaultMessage": "Additional comments",
"id": "report.placeholder"
},
{
"defaultMessage": "Submit",
"id": "report.submit"
},
{
"defaultMessage": "Report {target}",
"id": "report.target"
}
],
"path": "app/javascript/mastodon/features/ui/components/report_modal.json"
},
{
"descriptors": [
{

View File

@ -140,10 +140,10 @@
"privacy.unlisted.long": "Do not post to public timelines",
"privacy.unlisted.short": "Unlisted",
"reply_indicator.cancel": "Cancel",
"report.heading": "New report",
"report.heading": "Report {target}",
"report.placeholder": "Additional comments",
"report.submit": "Submit",
"report.target": "Reporting",
"report.target": "Reporting {target}",
"search.placeholder": "Search",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"status.cannot_reblog": "This post cannot be boosted",

View File

@ -27,8 +27,8 @@
"column.notifications": "Notifications",
"column.public": "Fil public global",
"column_back_button.label": "Retour",
"column_header.pin": "Pin",
"column_header.unpin": "Unpin",
"column_header.pin": "Épingler",
"column_header.unpin": "Retirer",
"column_subheading.navigation": "Navigation",
"column_subheading.settings": "Paramètres",
"compose_form.lock_disclaimer": "Votre compte n'est pas {locked}. Tout le monde peut vous suivre et voir vos pouets restreints.",
@ -101,7 +101,7 @@
"notifications.clear_confirmation": "Voulez-vous vraiment supprimer toutes vos notifications ?",
"notifications.column_settings.alert": "Notifications locales",
"notifications.column_settings.favourite": "Favoris :",
"notifications.column_settings.follow": "Nouveaux abonné⋅e⋅s :",
"notifications.column_settings.follow": "Nouveaux⋅elles abonné⋅s :",
"notifications.column_settings.mention": "Mentions :",
"notifications.column_settings.reblog": "Partages :",
"notifications.column_settings.show": "Afficher dans la colonne",

View File

@ -136,10 +136,10 @@
"privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
"privacy.unlisted.short": "Niewidoczne",
"reply_indicator.cancel": "Anuluj",
"report.heading": "Nowe zgłoszenie",
"report.heading": "Zgłoś {target}",
"report.placeholder": "Dodatkowe komentarze",
"report.submit": "Wyślij",
"report.target": "Zgłaszanie",
"report.target": "Zgłaszanie {target}",
"search.placeholder": "Szukaj",
"search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
"status.cannot_reblog": "Ten post nie może zostać podbity",

View File

@ -129,6 +129,11 @@
color: $ui-primary-color;
}
}
.positive-hint {
color: $valid-value-color;
font-weight: 500;
}
}
.simple_form {

View File

@ -58,37 +58,6 @@
position: relative;
}
.column-collapsable {
position: relative;
.column-collapsable__content {
overflow: auto;
transition: 300ms ease;
opacity: 1;
max-height: 70vh;
}
&.collapsed .column-collapsable__content {
height: 0 !important;
opacity: 0;
}
.column-collapsable__button {
color: $primary-text-color;
background: lighten($ui-base-color, 8%);
&:hover {
color: $primary-text-color;
background: lighten($ui-base-color, 8%);
}
}
&.collapsed .column-collapsable__button {
color: $ui-primary-color;
background: lighten($ui-base-color, 4%);
}
}
.column-icon {
background: lighten($ui-base-color, 4%);
color: $ui-primary-color;
@ -670,13 +639,15 @@
}
.status-check-box {
border-bottom: 1px solid lighten($ui-base-color, 8%);
border-bottom: 1px solid $ui-secondary-color;
display: flex;
.status__content {
background: lighten($ui-base-color, 4%);
flex: 1 1 auto;
padding: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@ -1233,20 +1204,22 @@
.image-loader {
position: relative;
}
.image-loader__preview-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
filter: blur(2px);
}
&.image-loader--loading {
.image-loader__preview-canvas {
filter: blur(2px);
}
}
.media-modal img.image-loader__preview-img {
width: 100%;
height: 100%;
.image-loader__img {
position: absolute;
top: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
background-image: none;
}
}
.navigation-bar {
@ -1980,6 +1953,17 @@
@include limited-single-column('screen and (max-width: 600px)') {
font-size: 16px;
}
&.light {
color: $ui-base-color;
border-bottom: 2px solid lighten($ui-base-color, 27%);
&:focus,
&:active {
color: $ui-base-color;
border-bottom-color: $ui-highlight-color;
}
}
}
@import 'boost';
@ -2231,11 +2215,6 @@ button.icon-button.active i.fa-retweet {
transition: max-height 150ms ease-in-out, opacity 300ms linear;
opacity: 1;
& > div {
background: lighten($ui-base-color, 8%);
padding: 15px;
}
&.collapsed {
max-height: 0;
opacity: 0.5;
@ -2246,6 +2225,11 @@ button.icon-button.active i.fa-retweet {
}
}
.column-header__collapsible-inner {
background: lighten($ui-base-color, 8%);
padding: 15px;
}
.column-header__setting-btn {
&:hover {
color: lighten($ui-primary-color, 4%);
@ -2437,67 +2421,6 @@ button.icon-button.active i.fa-retweet {
vertical-align: middle;
}
.report.scrollable {
box-sizing: border-box;
display: flex;
flex-direction: column;
max-height: 100%;
}
.report__target {
border-bottom: 1px solid lighten($ui-base-color, 4%);
color: $ui-secondary-color;
flex: 0 0 auto;
padding: 10px;
strong {
display: block;
color: $primary-text-color;
font-weight: 500;
}
}
.report__statuses {
flex: 1 1 auto;
}
.report__textarea-wrapper {
flex: 0 0 100px;
padding: 10px;
}
.report__textarea {
background: transparent;
box-sizing: border-box;
border: 0;
border-bottom: 2px solid $ui-primary-color;
border-radius: 2px 2px 0 0;
color: $primary-text-color;
display: block;
font-family: inherit;
font-size: 14px;
margin-bottom: 10px;
outline: 0;
padding: 7px 4px;
resize: vertical;
width: 100%;
&:active,
&:focus {
border-bottom-color: $ui-highlight-color;
background: rgba($base-overlay-background, 0.1);
}
}
.report__submit {
margin-top: 10px;
overflow: hidden;
}
.report__submit-button {
float: right;
}
.empty-column-indicator {
color: lighten($ui-base-color, 20%);
background: $ui-base-color;
@ -3086,6 +3009,7 @@ button.icon-button.active i.fa-retweet {
position: relative;
img,
canvas,
video {
max-width: 80vw;
max-height: 80vh;
@ -3093,7 +3017,8 @@ button.icon-button.active i.fa-retweet {
height: auto;
}
img {
img,
canvas {
display: block;
background: url('../images/void.png') repeat;
}
@ -3279,6 +3204,7 @@ button.icon-button.active i.fa-retweet {
@media screen and (max-width: 400px) {
.onboarding-modal__page-one {
flex-direction: column;
align-items: normal;
}
.onboarding-modal__page-one__elephant-friend {
@ -3393,7 +3319,8 @@ button.icon-button.active i.fa-retweet {
}
.boost-modal,
.confirmation-modal {
.confirmation-modal,
.report-modal {
background: lighten($ui-secondary-color, 8%);
color: $ui-base-color;
border-radius: 8px;
@ -3429,7 +3356,8 @@ button.icon-button.active i.fa-retweet {
}
.boost-modal__action-bar,
.confirmation-modal__action-bar {
.confirmation-modal__action-bar,
.report-modal__action-bar {
display: flex;
justify-content: space-between;
background: $ui-secondary-color;
@ -3465,6 +3393,23 @@ button.icon-button.active i.fa-retweet {
}
}
.report-modal__statuses,
.report-modal__comment {
padding: 10px;
}
.report-modal__statuses {
min-height: 20vh;
overflow-y: auto;
overflow-x: hidden;
}
.report-modal__comment {
.setting-text {
margin-top: 10px;
}
}
.confirmation-modal__action-bar {
.confirmation-modal__cancel-button {
background-color: transparent;
@ -3480,7 +3425,8 @@ button.icon-button.active i.fa-retweet {
}
}
.confirmation-modal__container {
.confirmation-modal__container,
.report-modal__target {
padding: 30px;
font-size: 16px;
text-align: center;
@ -3601,10 +3547,15 @@ button.icon-button.active i.fa-retweet {
background-repeat: no-repeat;
background-size: cover;
cursor: zoom-in;
display: block;
height: 100%;
display: flex;
align-items: center;
text-decoration: none;
width: 100%;
height: 100%;
&,
img {
width: 100%;
}
}
.media-gallery__gifv {

View File

@ -358,7 +358,6 @@ code {
}
.user_filtered_languages {
& > label {
font-family: inherit;
font-size: 16px;

View File

@ -10,7 +10,6 @@
.recovery-codes {
list-style: none;
margin: 0 auto;
text-align: center;
li {
font-size: 125%;

View File

@ -42,6 +42,18 @@
strong {
font-weight: 500;
}
&.inline-table {
td,
th {
padding: 8px 0;
}
& > tbody > tr:nth-child(odd) > td,
& > tbody > tr:nth-child(odd) > th {
background: transparent;
}
}
}
samp {

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class AdminMailer < ApplicationMailer
def new_report(recipient, report)
@report = report
@me = recipient
@instance = Rails.configuration.x.local_domain
locale_for_account(@me) do
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_report.subject', instance: @instance, id: @report.id)
end
end
end

View File

@ -4,4 +4,12 @@ class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch('SMTP_FROM_ADDRESS') { 'notifications@localhost' }
layout 'mailer'
helper :instance
protected
def locale_for_account(account)
I18n.with_locale(account.user_locale || I18n.default_locale) do
yield
end
end
end

View File

@ -67,12 +67,4 @@ class NotificationMailer < ApplicationMailer
)
end
end
private
def locale_for_account(account)
I18n.with_locale(account.user_locale || I18n.default_locale) do
yield
end
end
end

View File

@ -3,36 +3,78 @@
#
# Table name: session_activations
#
# id :integer not null, primary key
# user_id :integer not null
# session_id :string not null
# created_at :datetime not null
# updated_at :datetime not null
# id :integer not null, primary key
# user_id :integer not null
# session_id :string not null
# created_at :datetime not null
# updated_at :datetime not null
# user_agent :string default(""), not null
# ip :inet
# access_token_id :integer
#
class SessionActivation < ApplicationRecord
LIMIT = Rails.configuration.x.max_session_activations
belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
def self.active?(id)
id && where(session_id: id).exists?
delegate :token,
to: :access_token,
allow_nil: true
def detection
@detection ||= Browser.new(user_agent)
end
def self.activate(id)
activation = create!(session_id: id)
purge_old
activation
def browser
detection.id
end
def self.deactivate(id)
return unless id
where(session_id: id).destroy_all
def platform
detection.platform.id
end
def self.purge_old
order('created_at desc').offset(LIMIT).destroy_all
before_create :assign_access_token
before_save :assign_user_agent
class << self
def active?(id)
id && where(session_id: id).exists?
end
def activate(options = {})
activation = create!(options)
purge_old
activation
end
def deactivate(id)
return unless id
where(session_id: id).destroy_all
end
def purge_old
order('created_at desc').offset(Rails.configuration.x.max_session_activations).destroy_all
end
def exclusive(id)
where('session_id != ?', id).destroy_all
end
end
def self.exclusive(id)
where('session_id != ?', id).destroy_all
private
def assign_user_agent
self.user_agent = '' if user_agent.nil?
end
def assign_access_token
superapp = Doorkeeper::Application.find_by(superapp: true)
return if superapp.nil?
self.access_token = Doorkeeper::AccessToken.create!(application_id: superapp.id,
resource_owner_id: user_id,
scopes: 'read write follow',
expires_in: Doorkeeper.configuration.access_token_expires_in,
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?)
end
end

View File

@ -91,8 +91,10 @@ class User < ApplicationRecord
settings.auto_play_gif
end
def activate_session
session_activations.activate(SecureRandom.hex).session_id
def activate_session(request)
session_activations.activate(session_id: SecureRandom.hex,
user_agent: request.user_agent,
ip: request.ip).session_id
end
def exclusive_session(id)

View File

@ -13,7 +13,8 @@ class SendInteractionService < BaseService
return if block_notification?
envelope = salmon.pack(@xml, @source_account.keypair)
salmon.post(@target_account.salmon_url, envelope)
delivery = salmon.post(@target_account.salmon_url, envelope)
raise "Delivery failed for #{target_account.salmon_url}: HTTP #{delivery.code}" unless delivery.code > 199 && delivery.code < 300
end
private

View File

@ -0,0 +1,5 @@
<%= display_name(@me) %>,
<%= raw t('admin_mailer.new_report.body', target: @report.target_account.acct, reporter: @report.account.acct) %>
<%= raw t('application_mailer.view')%> <%= admin_report_url(@report) %>

View File

@ -0,0 +1,23 @@
%h6= t 'sessions.title'
%p.muted-hint= t 'sessions.explanation'
%table.table.inline-table
%thead
%tr
%th= t 'sessions.browser'
%th= t 'sessions.ip'
%th= t 'sessions.activity'
%tbody
- @sessions.each do |session|
%tr
%td
%span{ title: session.user_agent }= fa_icon session_device_icon(session)
= ' '
= t 'sessions.description', browser: t("sessions.browsers.#{session.browser}"), platform: t("sessions.platforms.#{session.platform}")
%td
%samp= session.ip
%td
- if request.session['auth_id'] == session.session_id
= t 'sessions.current_session'
- else
%time.time-ago{ datetime: session.updated_at.iso8601, title: l(session.updated_at) }= l(session.updated_at)

View File

@ -12,6 +12,10 @@
.actions
= f.button :button, t('generic.save_changes'), type: :submit
%hr/
= render 'sessions'
- if open_deletion?
%hr/

View File

@ -1,7 +1,7 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
%p.hint= t('two_factor_authentication.recovery_instructions')
%p.hint= t('two_factor_authentication.recovery_instructions_html')
%ol.recovery-codes
- @recovery_codes.each do |code|

View File

@ -1,26 +1,34 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
.simple_form
%p.hint
= t('two_factor_authentication.description_html')
- if current_user.otp_required_for_login
%p.positive-hint
= fa_icon 'check'
= ' '
= t 'two_factor_authentication.enabled'
%hr/
= simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
= f.input :code, hint: t('two_factor_authentication.code_hint'), placeholder: t('simple_form.labels.defaults.otp_attempt')
.actions
= f.button :button, t('two_factor_authentication.disable'), type: :submit
%hr/
%h6= t('two_factor_authentication.recovery_codes')
%p.muted-hint
= t('two_factor_authentication.lost_recovery_codes')
= link_to t('two_factor_authentication.generate_recovery_codes'),
settings_two_factor_authentication_recovery_codes_path,
data: { method: :post }
- else
.simple_form
%p.hint= t('two_factor_authentication.description_html')
- if current_user.otp_required_for_login
= link_to t('two_factor_authentication.disable'),
settings_two_factor_authentication_path,
data: { method: :delete },
class: 'block-button'
- else
= link_to t('two_factor_authentication.setup'),
settings_two_factor_authentication_path,
data: { method: :post },
class: 'block-button'
- if current_user.otp_required_for_login
.simple_form
%p.hint
= t('two_factor_authentication.lost_recovery_codes')
= link_to t('two_factor_authentication.generate_recovery_codes'),
settings_two_factor_authentication_recovery_codes_path,
data: { method: :post },
class: 'block-button'

View File

@ -1,3 +1,3 @@
<p>Hello <%= @resource.email %>!</p>
<p>We're contacting you to notify you that your password on Mastodon has been changed.</p>
<p>We're contacting you to notify you that your password on <%= @instance %> has been changed.</p>

View File

@ -1,3 +1,3 @@
Hello <%= @resource.email %>!
We're contacting you to notify you that your password on Mastodon has been changed.
We're contacting you to notify you that your password on <%= @instance %> has been changed.

View File

@ -1,6 +1,6 @@
<p>Hello <%= @resource.email %>!</p>
<p>Someone has requested a link to change your password on Mastodon. You can do this through the link below.</p>
<p>Someone has requested a link to change your password on <%= @instance %>. You can do this through the link below.</p>
<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>

View File

@ -1,6 +1,6 @@
Hello <%= @resource.email %>!
Someone has requested a link to change your password on Mastodon. You can do this through the link below.
Someone has requested a link to change your password on <%= @instance %>. You can do this through the link below.
<%= edit_password_url(@resource, reset_password_token: @token) %>

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'sidekiq-scheduler'
class Scheduler::DoorkeeperCleanupScheduler
include Sidekiq::Worker
def perform
Doorkeeper::AccessToken.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
Doorkeeper::AccessGrant.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
end
end