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

Conflicts:
- `package.json`:
  Not really a conflict, just some glitch-soc-specific dependency
  too close to an upstream-updated one.
main
Thibaut Girka 2020-07-07 15:34:00 +02:00
commit e9ad99bc93
40 changed files with 1762 additions and 1363 deletions

View File

@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.5' gem 'pghero', '~> 2.5'
gem 'dotenv-rails', '~> 2.7' gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.72', require: false gem 'aws-sdk-s3', '~> 1.73', require: false
gem 'fog-core', '<= 2.1.0' gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0' gem 'paperclip', '~> 6.0'

View File

@ -92,16 +92,16 @@ GEM
av (0.9.0) av (0.9.0)
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
aws-eventstream (1.1.0) aws-eventstream (1.1.0)
aws-partitions (1.336.0) aws-partitions (1.338.0)
aws-sdk-core (3.102.1) aws-sdk-core (3.103.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0) aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-kms (1.35.0) aws-sdk-kms (1.36.0)
aws-sdk-core (~> 3, >= 3.99.0) aws-sdk-core (~> 3, >= 3.99.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.72.0) aws-sdk-s3 (1.73.0)
aws-sdk-core (~> 3, >= 3.102.1) aws-sdk-core (~> 3, >= 3.102.1)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
@ -189,7 +189,7 @@ GEM
devise_pam_authenticatable2 (9.2.0) devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0) devise (>= 4.0.0)
rpam2 (~> 4.0) rpam2 (~> 4.0)
diff-lcs (1.4.3) diff-lcs (1.4.4)
discard (1.2.0) discard (1.2.0)
activerecord (>= 4.2, < 7) activerecord (>= 4.2, < 7)
docile (1.3.2) docile (1.3.2)
@ -302,7 +302,7 @@ GEM
ipaddress (0.8.3) ipaddress (0.8.3)
iso-639 (0.3.5) iso-639 (0.3.5)
jmespath (1.4.0) jmespath (1.4.0)
json (2.3.0) json (2.3.1)
json-canonicalization (0.2.0) json-canonicalization (0.2.0)
json-ld (3.1.4) json-ld (3.1.4)
htmlentities (~> 4.3) htmlentities (~> 4.3)
@ -391,9 +391,9 @@ GEM
addressable (~> 2.3) addressable (~> 2.3)
nokogiri (~> 1.5) nokogiri (~> 1.5)
omniauth (~> 1.2) omniauth (~> 1.2)
omniauth-saml (1.10.1) omniauth-saml (1.10.2)
omniauth (~> 1.3, >= 1.3.2) omniauth (~> 1.3, >= 1.3.2)
ruby-saml (~> 1.7) ruby-saml (~> 1.9)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ox (2.13.2) ox (2.13.2)
paperclip (6.0.0) paperclip (6.0.0)
@ -484,7 +484,7 @@ GEM
thor (>= 0.19.0, < 2.0) thor (>= 0.19.0, < 2.0)
rainbow (3.0.0) rainbow (3.0.0)
rake (13.0.1) rake (13.0.1)
rdf (3.1.3) rdf (3.1.4)
hamster (~> 3.0) hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.4.0) rdf-normalize (0.4.0)
@ -673,7 +673,7 @@ DEPENDENCIES
active_record_query_trace (~> 1.7) active_record_query_trace (~> 1.7)
addressable (~> 2.7) addressable (~> 2.7)
annotate (~> 3.1) annotate (~> 3.1)
aws-sdk-s3 (~> 1.72) aws-sdk-s3 (~> 1.73)
better_errors (~> 2.7) better_errors (~> 2.7)
binding_of_caller (~> 0.7) binding_of_caller (~> 0.7)
blurhash (~> 0.1) blurhash (~> 0.1)

View File

@ -9,7 +9,10 @@ class Auth::PasswordsController < Devise::PasswordsController
def update def update
super do |resource| super do |resource|
resource.session_activations.destroy_all if resource.errors.empty? if resource.errors.empty?
resource.session_activations.destroy_all
resource.forget_me!
end
end end
end end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Auth::RegistrationsController < Devise::RegistrationsController class Auth::RegistrationsController < Devise::RegistrationsController
include Devise::Controllers::Rememberable
layout :determine_layout layout :determine_layout
before_action :set_invite, only: [:new, :create] before_action :set_invite, only: [:new, :create]
@ -25,7 +27,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def update def update
super do |resource| super do |resource|
resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password? if resource.saved_change_to_encrypted_password?
resource.clear_other_sessions(current_session.session_id)
resource.forget_me!
remember_me(resource)
end
end end
end end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class HomeController < ApplicationController class HomeController < ApplicationController
before_action :redirect_unauthenticated_to_permalinks!
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_pack before_action :set_pack
@ -12,7 +13,7 @@ class HomeController < ApplicationController
private private
def authenticate_user! def redirect_unauthenticated_to_permalinks!
return if user_signed_in? return if user_signed_in?
matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/) matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/)
@ -37,6 +38,7 @@ class HomeController < ApplicationController
end end
matches = request.path.match(%r{\A/web/timelines/tag/(?<tag>.+)\z}) matches = request.path.match(%r{\A/web/timelines/tag/(?<tag>.+)\z})
redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path) redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path)
end end

View File

@ -2,6 +2,7 @@
class MediaProxyController < ApplicationController class MediaProxyController < ApplicationController
include RoutingHelper include RoutingHelper
include Authorization
skip_before_action :store_current_location skip_before_action :store_current_location
skip_before_action :require_functional! skip_before_action :require_functional!
@ -10,12 +11,14 @@ class MediaProxyController < ApplicationController
rescue_from ActiveRecord::RecordInvalid, with: :not_found rescue_from ActiveRecord::RecordInvalid, with: :not_found
rescue_from Mastodon::UnexpectedResponseError, with: :not_found rescue_from Mastodon::UnexpectedResponseError, with: :not_found
rescue_from Mastodon::NotPermittedError, with: :not_found
rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error
def show def show
RedisLock.acquire(lock_options) do |lock| RedisLock.acquire(lock_options) do |lock|
if lock.acquired? if lock.acquired?
@media_attachment = MediaAttachment.remote.find(params[:id]) @media_attachment = MediaAttachment.remote.attached.find(params[:id])
authorize @media_attachment.status, :show?
redownload! if @media_attachment.needs_redownload? && !reject_media? redownload! if @media_attachment.needs_redownload? && !reject_media?
else else
raise Mastodon::RaceConditionError raise Mastodon::RaceConditionError

View File

@ -4,19 +4,12 @@ export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT'; export function submitAccountNote(id, value) {
export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL';
export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
export function submitAccountNote() {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(submitAccountNoteRequest()); dispatch(submitAccountNoteRequest());
const id = getState().getIn(['account_notes', 'edit', 'account_id']);
api(getState).post(`/api/v1/accounts/${id}/note`, { api(getState).post(`/api/v1/accounts/${id}/note`, {
comment: getState().getIn(['account_notes', 'edit', 'comment']), comment: value,
}).then(response => { }).then(response => {
dispatch(submitAccountNoteSuccess(response.data)); dispatch(submitAccountNoteSuccess(response.data));
}).catch(error => dispatch(submitAccountNoteFail(error))); }).catch(error => dispatch(submitAccountNoteFail(error)));
@ -42,28 +35,3 @@ export function submitAccountNoteFail(error) {
error, error,
}; };
}; };
export function initEditAccountNote(account) {
return (dispatch, getState) => {
const comment = getState().getIn(['relationships', account.get('id'), 'note']);
dispatch({
type: ACCOUNT_NOTE_INIT_EDIT,
account,
comment,
});
};
};
export function cancelAccountNote() {
return {
type: ACCOUNT_NOTE_CANCEL,
};
};
export function changeAccountNoteComment(comment) {
return {
type: ACCOUNT_NOTE_CHANGE_COMMENT,
comment,
};
};

View File

@ -28,6 +28,11 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST';
export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS';
export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL';
export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
@ -260,6 +265,49 @@ export function uploadCompose(files) {
}; };
}; };
export const uploadThumbnail = (id, file) => (dispatch, getState) => {
dispatch(uploadThumbnailRequest());
const total = file.size;
const data = new FormData();
data.append('thumbnail', file);
api(getState).put(`/api/v1/media/${id}`, data, {
onUploadProgress: ({ loaded }) => {
dispatch(uploadThumbnailProgress(loaded, total));
},
}).then(({ data }) => {
dispatch(uploadThumbnailSuccess(data));
}).catch(error => {
dispatch(uploadThumbnailFail(id, error));
});
};
export const uploadThumbnailRequest = () => ({
type: THUMBNAIL_UPLOAD_REQUEST,
skipLoading: true,
});
export const uploadThumbnailProgress = (loaded, total) => ({
type: THUMBNAIL_UPLOAD_PROGRESS,
loaded,
total,
skipLoading: true,
});
export const uploadThumbnailSuccess = media => ({
type: THUMBNAIL_UPLOAD_SUCCESS,
media,
skipLoading: true,
});
export const uploadThumbnailFail = error => ({
type: THUMBNAIL_UPLOAD_FAIL,
error,
skipLoading: true,
});
export function changeUploadCompose(id, params) { export function changeUploadCompose(id, params) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(changeUploadComposeRequest()); dispatch(changeUploadComposeRequest());
@ -278,6 +326,7 @@ export function changeUploadComposeRequest() {
skipLoading: true, skipLoading: true,
}; };
}; };
export function changeUploadComposeSuccess(media) { export function changeUploadComposeSuccess(media) {
return { return {
type: COMPOSE_UPLOAD_CHANGE_SUCCESS, type: COMPOSE_UPLOAD_CHANGE_SUCCESS,

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { shortNumberFormat } from 'mastodon/utils/numbers'; import ShortNumber from 'mastodon/components/short_number';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
export default class AutosuggestHashtag extends React.PureComponent { export default class AutosuggestHashtag extends React.PureComponent {
@ -13,14 +13,28 @@ export default class AutosuggestHashtag extends React.PureComponent {
}).isRequired, }).isRequired,
}; };
render () { render() {
const { tag } = this.props; const { tag } = this.props;
const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); const weeklyUses = tag.history && (
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
);
return ( return (
<div className='autosuggest-hashtag'> <div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div> <div className='autosuggest-hashtag__name'>
{tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>} #<strong>{tag.name}</strong>
</div>
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<FormattedMessage
id='autosuggest_hashtag.per_week'
defaultMessage='{count} per week'
values={{ count: weeklyUses }}
/>
</div>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,62 @@
// @ts-check
import React from 'react';
import { FormattedMessage } from 'react-intl';
/**
* Returns custom renderer for one of the common counter types
*
* @param {"statuses" | "following" | "followers"} counterType
* Type of the counter
* @param {boolean} isBold Whether display number must be displayed in bold
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
* Renderer function
* @throws If counterType is not covered by this function
*/
export function counterRenderer(counterType, isBold = true) {
/**
* @type {(displayNumber: JSX.Element) => JSX.Element}
*/
const renderCounter = isBold
? (displayNumber) => <strong>{displayNumber}</strong>
: (displayNumber) => displayNumber;
switch (counterType) {
case 'statuses': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Toot} other {{counter} Toots}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'following': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, other {{counter} Following}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'followers': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
}
}

View File

@ -1,26 +1,65 @@
// @ts-check
import React from 'react'; import React from 'react';
import { Sparklines, SparklinesCurve } from 'react-sparklines'; import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from './permalink'; import Permalink from './permalink';
import { shortNumberFormat } from '../utils/numbers'; import ShortNumber from 'mastodon/components/short_number';
/**
* Used to render counter of how much people are talking about hashtag
*
* @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
*/
const accountsCountRenderer = (displayNumber, pluralReady) => (
<FormattedMessage
id='trends.counter_by_accounts'
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
const Hashtag = ({ hashtag }) => ( const Hashtag = ({ hashtag }) => (
<div className='trends__item'> <div className='trends__item'>
<div className='trends__item__name'> <div className='trends__item__name'>
<Permalink href={hashtag.get('url')} to={`/timelines/tag/${hashtag.get('name')}`}> <Permalink
href={hashtag.get('url')}
to={`/timelines/tag/${hashtag.get('name')}`}
>
#<span>{hashtag.get('name')}</span> #<span>{hashtag.get('name')}</span>
</Permalink> </Permalink>
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} /> <ShortNumber
value={
hashtag.getIn(['history', 0, 'accounts']) * 1 +
hashtag.getIn(['history', 1, 'accounts']) * 1
}
renderer={accountsCountRenderer}
/>
</div> </div>
<div className='trends__item__current'> <div className='trends__item__current'>
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)} <ShortNumber
value={
hashtag.getIn(['history', 0, 'uses']) * 1 +
hashtag.getIn(['history', 1, 'uses']) * 1
}
/>
</div> </div>
<div className='trends__item__sparkline'> <div className='trends__item__sparkline'>
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}> <Sparklines
width={50}
height={28}
data={hashtag
.get('history')
.reverse()
.map((day) => day.get('uses'))
.toArray()}
>
<SparklinesCurve style={{ fill: 'none' }} /> <SparklinesCurve style={{ fill: 'none' }} />
</Sparklines> </Sparklines>
</div> </div>

View File

@ -0,0 +1,117 @@
import React from 'react';
import PropTypes from 'prop-types';
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
import { FormattedMessage, FormattedNumber } from 'react-intl';
// @ts-check
/**
* @callback ShortNumberRenderer
* @param {JSX.Element} displayNumber Number to display
* @param {number} pluralReady Number used for pluralization
* @returns {JSX.Element} Final render of number
*/
/**
* @typedef {object} ShortNumberProps
* @property {number} value Number to display in short variant
* @property {ShortNumberRenderer} [renderer]
* Custom renderer for numbers, provided as a prop. If another renderer
* passed as a child of this component, this prop won't be used.
* @property {ShortNumberRenderer} [children]
* Custom renderer for numbers, provided as a child. If another renderer
* passed as a prop of this component, this one will be used instead.
*/
/**
* Component that renders short big number to a shorter version
*
* @param {ShortNumberProps} param0 Props for the component
* @returns {JSX.Element} Rendered number
*/
function ShortNumber({ value, renderer, children }) {
const shortNumber = toShortNumber(value);
const [, division] = shortNumber;
// eslint-disable-next-line eqeqeq
if (children != null && renderer != null) {
console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
}
// eslint-disable-next-line eqeqeq
const customRenderer = children != null ? children : renderer;
const displayNumber = <ShortNumberCounter value={shortNumber} />;
// eslint-disable-next-line eqeqeq
return customRenderer != null
? customRenderer(displayNumber, pluralReady(value, division))
: displayNumber;
}
ShortNumber.propTypes = {
value: PropTypes.number.isRequired,
renderer: PropTypes.func,
children: PropTypes.func,
};
/**
* @typedef {object} ShortNumberCounterProps
* @property {import('../utils/number').ShortNumber} value Short number
*/
/**
* Renders short number into corresponding localizable react fragment
*
* @param {ShortNumberCounterProps} param0 Props for the component
* @returns {JSX.Element} FormattedMessage ready to be embedded in code
*/
function ShortNumberCounter({ value }) {
const [rawNumber, unit, maxFractionDigits = 0] = value;
const count = (
<FormattedNumber
value={rawNumber}
maximumFractionDigits={maxFractionDigits}
/>
);
let values = { count, rawNumber };
switch (unit) {
case DECIMAL_UNITS.THOUSAND: {
return (
<FormattedMessage
id='units.short.thousand'
defaultMessage='{count}K'
values={values}
/>
);
}
case DECIMAL_UNITS.MILLION: {
return (
<FormattedMessage
id='units.short.million'
defaultMessage='{count}M'
values={values}
/>
);
}
case DECIMAL_UNITS.BILLION: {
return (
<FormattedMessage
id='units.short.billion'
defaultMessage='{count}B'
values={values}
/>
);
}
// Not sure if we should go farther - @Sasha-Sorokin
default: return count;
}
}
ShortNumberCounter.propTypes = {
value: PropTypes.arrayOf(PropTypes.number),
};
export default React.memo(ShortNumber);

View File

@ -3,99 +3,166 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'mastodon/components/icon';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import { is } from 'immutable';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' }, placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
}); });
export default @injectIntl class InlineAlert extends React.PureComponent {
class Header extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, show: PropTypes.bool,
isEditing: PropTypes.bool,
isSubmitting: PropTypes.bool,
accountNote: PropTypes.string,
onEditAccountNote: PropTypes.func.isRequired,
onCancelAccountNote: PropTypes.func.isRequired,
onSaveAccountNote: PropTypes.func.isRequired,
onChangeAccountNote: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}; };
handleChangeAccountNote = (e) => { state = {
this.props.onChangeAccountNote(e.target.value); mountMessage: false,
}; };
componentWillUnmount () { static TRANSITION_DELAY = 200;
if (this.props.isEditing) {
this.props.onCancelAccountNote();
}
}
handleKeyDown = e => { componentWillReceiveProps (nextProps) {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { if (!this.props.show && nextProps.show) {
this.props.onSaveAccountNote(); this.setState({ mountMessage: true });
} else if (e.keyCode === 27) { } else if (this.props.show && !nextProps.show) {
this.props.onCancelAccountNote(); setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
} }
} }
render () { render () {
const { account, accountNote, isEditing, isSubmitting, intl } = this.props; const { show } = this.props;
const { mountMessage } = this.state;
if (!account || (!accountNote && !isEditing)) { return (
<span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
{mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
</span>
);
}
}
export default @injectIntl
class AccountNote extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
value: PropTypes.string,
onSave: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
value: null,
saving: false,
saved: false,
};
componentWillMount () {
this._reset();
}
componentWillReceiveProps (nextProps) {
const accountWillChange = !is(this.props.account, nextProps.account);
const newState = {};
if (accountWillChange && this._isDirty()) {
this._save(false);
}
if (accountWillChange || nextProps.value === this.state.value) {
newState.saving = false;
}
if (this.props.value !== nextProps.value) {
newState.value = nextProps.value;
}
this.setState(newState);
}
componentWillUnmount () {
if (this._isDirty()) {
this._save(false);
}
}
setTextareaRef = c => {
this.textarea = c;
}
handleChange = e => {
this.setState({ value: e.target.value, saving: false });
};
handleKeyDown = e => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this._save();
if (this.textarea) {
this.textarea.blur();
}
} else if (e.keyCode === 27) {
e.preventDefault();
this._reset(() => {
if (this.textarea) {
this.textarea.blur();
}
});
}
}
handleBlur = () => {
if (this._isDirty()) {
this._save();
}
}
_save (showMessage = true) {
this.setState({ saving: true }, () => this.props.onSave(this.state.value));
if (showMessage) {
this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
}
}
_reset (callback) {
this.setState({ value: this.props.value }, callback);
}
_isDirty () {
return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
}
render () {
const { account, intl } = this.props;
const { value, saved } = this.state;
if (!account) {
return null; return null;
} }
let action_buttons = null;
if (isEditing) {
action_buttons = (
<div className='account__header__account-note__buttons'>
<button className='text-btn' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
<Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
</button>
<div className='flex-spacer' />
<button className='text-btn' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
<Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
</button>
</div>
);
}
let note_container = null;
if (isEditing) {
note_container = (
<Textarea
className='account__header__account-note__content'
disabled={isSubmitting}
placeholder={intl.formatMessage(messages.placeholder)}
value={accountNote}
onChange={this.handleChangeAccountNote}
onKeyDown={this.handleKeyDown}
autoFocus
/>
);
} else {
note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
}
return ( return (
<div className='account__header__account-note'> <div className='account__header__account-note'>
<div className='account__header__account-note__header'> <label htmlFor={`account-note-${account.get('id')}`}>
<strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong> <FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
{!isEditing && ( </label>
<div>
<button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}> <Textarea
<Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' /> id={`account-note-${account.get('id')}`}
</button> className='account__header__account-note__content'
</div> disabled={this.props.value === null || value === null}
)} placeholder={intl.formatMessage(messages.placeholder)}
</div> value={value || ''}
{note_container} onChange={this.handleChange}
{action_buttons} onKeyDown={this.handleKeyDown}
onBlur={this.handleBlur}
ref={this.setTextareaRef}
/>
</div> </div>
); );
} }

View File

@ -8,7 +8,8 @@ import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import Avatar from 'mastodon/components/avatar'; import Avatar from 'mastodon/components/avatar';
import { shortNumberFormat } from 'mastodon/utils/numbers'; import { counterRenderer } from 'mastodon/components/common_counter';
import ShortNumber from 'mastodon/components/short_number';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import AccountNoteContainer from '../containers/account_note_container'; import AccountNoteContainer from '../containers/account_note_container';
@ -66,7 +67,6 @@ class Header extends ImmutablePureComponent {
identity_props: ImmutablePropTypes.list, identity_props: ImmutablePropTypes.list,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onEditAccountNote: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
}; };
@ -131,8 +131,6 @@ class Header extends ImmutablePureComponent {
return null; return null;
} }
const accountNote = account.getIn(['relationship', 'note']);
let info = []; let info = [];
let actionBtn = ''; let actionBtn = '';
let lockedIcon = ''; let lockedIcon = '';
@ -183,10 +181,6 @@ class Header extends ImmutablePureComponent {
menu.push(null); menu.push(null);
} }
if (accountNote === null) {
menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
}
if (account.get('id') === me) { if (account.get('id') === me) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }); menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
@ -293,8 +287,6 @@ class Header extends ImmutablePureComponent {
</h1> </h1>
</div> </div>
<AccountNoteContainer account={account} />
<div className='account__header__extra'> <div className='account__header__extra'>
<div className='account__header__bio'> <div className='account__header__bio'>
{ (fields.size > 0 || identity_proofs.size > 0) && ( { (fields.size > 0 || identity_proofs.size > 0) && (
@ -323,20 +315,31 @@ class Header extends ImmutablePureComponent {
</div> </div>
)} )}
{account.get('id') !== me && <AccountNoteContainer account={account} />}
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />} {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
</div> </div>
<div className='account__header__extra__links'> <div className='account__header__extra__links'>
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}> <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
<strong>{shortNumberFormat(account.get('statuses_count'))}</strong> <FormattedMessage id='account.posts' defaultMessage='Toots' /> <ShortNumber
value={account.get('statuses_count')}
renderer={counterRenderer('statuses')}
/>
</NavLink> </NavLink>
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}> <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
<strong>{shortNumberFormat(account.get('following_count'))}</strong> <FormattedMessage id='account.follows' defaultMessage='Follows' /> <ShortNumber
value={account.get('following_count')}
renderer={counterRenderer('following')}
/>
</NavLink> </NavLink>
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}> <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
<strong>{shortNumberFormat(account.get('followers_count'))}</strong> <FormattedMessage id='account.followers' defaultMessage='Followers' /> <ShortNumber
value={account.get('followers_count')}
renderer={counterRenderer('followers')}
/>
</NavLink> </NavLink>
</div> </div>
</div> </div>

View File

@ -1,34 +1,17 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'mastodon/actions/account_notes'; import { submitAccountNote } from 'mastodon/actions/account_notes';
import AccountNote from '../components/account_note'; import AccountNote from '../components/account_note';
const mapStateToProps = (state, { account }) => { const mapStateToProps = (state, { account }) => ({
const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id'); value: account.getIn(['relationship', 'note']),
});
return {
isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
isEditing,
};
};
const mapDispatchToProps = (dispatch, { account }) => ({ const mapDispatchToProps = (dispatch, { account }) => ({
onEditAccountNote() { onSave (value) {
dispatch(initEditAccountNote(account)); dispatch(submitAccountNote(account.get('id'), value));
}, },
onSaveAccountNote() {
dispatch(submitAccountNote());
},
onCancelAccountNote() {
dispatch(cancelAccountNote());
},
onChangeAccountNote(comment) {
dispatch(changeAccountNoteComment(comment));
},
}); });
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote); export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);

View File

@ -19,7 +19,6 @@ import { initBlockModal } from '../../../actions/blocks';
import { initReport } from '../../../actions/reports'; import { initReport } from '../../../actions/reports';
import { openModal } from '../../../actions/modal'; import { openModal } from '../../../actions/modal';
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
import { initEditAccountNote } from 'mastodon/actions/account_notes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { unfollowModal } from '../../../initial_state'; import { unfollowModal } from '../../../initial_state';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
@ -103,10 +102,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onEditAccountNote (account) {
dispatch(initEditAccountNote(account));
},
onBlockDomain (domain) { onBlockDomain (domain) {
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />, message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,

View File

@ -11,8 +11,14 @@ import RelativeTimestamp from 'mastodon/components/relative_timestamp';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state'; import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
import { shortNumberFormat } from 'mastodon/utils/numbers'; import ShortNumber from 'mastodon/components/short_number';
import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts'; import {
followAccount,
unfollowAccount,
blockAccount,
unblockAccount,
unmuteAccount,
} from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { initMuteModal } from 'mastodon/actions/mutes'; import { initMuteModal } from 'mastodon/actions/mutes';
@ -22,7 +28,10 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, unfollowConfirm: {
id: 'confirmations.unfollow.confirm',
defaultMessage: 'Unfollow',
},
}); });
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
@ -36,15 +45,25 @@ const makeMapStateToProps = () => {
}; };
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow(account) {
onFollow (account) { if (
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { account.getIn(['relationship', 'following']) ||
account.getIn(['relationship', 'requested'])
) {
if (unfollowModal) { if (unfollowModal) {
dispatch(openModal('CONFIRM', { dispatch(
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, openModal('CONFIRM', {
confirm: intl.formatMessage(messages.unfollowConfirm), message: (
onConfirm: () => dispatch(unfollowAccount(account.get('id'))), <FormattedMessage
})); id='confirmations.unfollow.message'
defaultMessage='Are you sure you want to unfollow {name}?'
values={{ name: <strong>@{account.get('acct')}</strong> }}
/>
),
confirm: intl.formatMessage(messages.unfollowConfirm),
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
}),
);
} else { } else {
dispatch(unfollowAccount(account.get('id'))); dispatch(unfollowAccount(account.get('id')));
} }
@ -53,7 +72,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onBlock (account) { onBlock(account) {
if (account.getIn(['relationship', 'blocking'])) { if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id'))); dispatch(unblockAccount(account.get('id')));
} else { } else {
@ -61,17 +80,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onMute (account) { onMute(account) {
if (account.getIn(['relationship', 'muting'])) { if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id'))); dispatch(unmuteAccount(account.get('id')));
} else { } else {
dispatch(initMuteModal(account)); dispatch(initMuteModal(account));
} }
}, },
}); });
export default @injectIntl export default
@injectIntl
@connect(makeMapStateToProps, mapDispatchToProps) @connect(makeMapStateToProps, mapDispatchToProps)
class AccountCard extends ImmutablePureComponent { class AccountCard extends ImmutablePureComponent {
@ -83,7 +102,7 @@ class AccountCard extends ImmutablePureComponent {
onMute: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired,
}; };
_updateEmojis () { _updateEmojis() {
const node = this.node; const node = this.node;
if (!node || autoPlayGif) { if (!node || autoPlayGif) {
@ -104,68 +123,113 @@ class AccountCard extends ImmutablePureComponent {
} }
} }
componentDidMount () { componentDidMount() {
this._updateEmojis(); this._updateEmojis();
} }
componentDidUpdate () { componentDidUpdate() {
this._updateEmojis(); this._updateEmojis();
} }
handleEmojiMouseEnter = ({ target }) => { handleEmojiMouseEnter = ({ target }) => {
target.src = target.getAttribute('data-original'); target.src = target.getAttribute('data-original');
} };
handleEmojiMouseLeave = ({ target }) => { handleEmojiMouseLeave = ({ target }) => {
target.src = target.getAttribute('data-static'); target.src = target.getAttribute('data-static');
} };
handleFollow = () => { handleFollow = () => {
this.props.onFollow(this.props.account); this.props.onFollow(this.props.account);
} };
handleBlock = () => { handleBlock = () => {
this.props.onBlock(this.props.account); this.props.onBlock(this.props.account);
} };
handleMute = () => { handleMute = () => {
this.props.onMute(this.props.account); this.props.onMute(this.props.account);
} };
setRef = (c) => { setRef = (c) => {
this.node = c; this.node = c;
} };
render () { render() {
const { account, intl } = this.props; const { account, intl } = this.props;
let buttons; let buttons;
if (account.get('id') !== me && account.get('relationship', null) !== null) { if (
account.get('id') !== me &&
account.get('relationship', null) !== null
) {
const following = account.getIn(['relationship', 'following']); const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']); const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']); const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']); const muting = account.getIn(['relationship', 'muting']);
if (requested) { if (requested) {
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; buttons = (
<IconButton
disabled
icon='hourglass'
title={intl.formatMessage(messages.requested)}
/>
);
} else if (blocking) { } else if (blocking) {
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; buttons = (
<IconButton
active
icon='unlock'
title={intl.formatMessage(messages.unblock, {
name: account.get('username'),
})}
onClick={this.handleBlock}
/>
);
} else if (muting) { } else if (muting) {
buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />; buttons = (
<IconButton
active
icon='volume-up'
title={intl.formatMessage(messages.unmute, {
name: account.get('username'),
})}
onClick={this.handleMute}
/>
);
} else if (!account.get('moved') || following) { } else if (!account.get('moved') || following) {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; buttons = (
<IconButton
icon={following ? 'user-times' : 'user-plus'}
title={intl.formatMessage(
following ? messages.unfollow : messages.follow,
)}
onClick={this.handleFollow}
active={following}
/>
);
} }
} }
return ( return (
<div className='directory__card'> <div className='directory__card'>
<div className='directory__card__img'> <div className='directory__card__img'>
<img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' /> <img
src={
autoPlayGif ? account.get('header') : account.get('header_static')
}
alt=''
/>
</div> </div>
<div className='directory__card__bar'> <div className='directory__card__bar'>
<Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> <Permalink
className='directory__card__bar__name'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
>
<Avatar account={account} size={48} /> <Avatar account={account} size={48} />
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>
@ -176,13 +240,44 @@ class AccountCard extends ImmutablePureComponent {
</div> </div>
<div className='directory__card__extra' ref={this.setRef}> <div className='directory__card__extra' ref={this.setRef}>
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} /> <div
className='account__header__content'
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/>
</div> </div>
<div className='directory__card__extra'> <div className='directory__card__extra'>
<div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div> <div className='accounts-table__count'>
<div className='accounts-table__count'>{shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div> <ShortNumber value={account.get('statuses_count')} />
<div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div> <small>
<FormattedMessage id='account.posts' defaultMessage='Toots' />
</small>
</div>
<div className='accounts-table__count'>
<ShortNumber value={account.get('followers_count')} />{' '}
<small>
<FormattedMessage
id='account.followers'
defaultMessage='Followers'
/>
</small>
</div>
<div className='accounts-table__count'>
{account.get('last_status_at') === null ? (
<FormattedMessage
id='account.never_active'
defaultMessage='Never'
/>
) : (
<RelativeTimestamp timestamp={account.get('last_status_at')} />
)}{' '}
<small>
<FormattedMessage
id='account.last_status'
defaultMessage='Last active'
/>
</small>
</div>
</div> </div>
</div> </div>
); );

View File

@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle'; import Toggle from 'react-toggle';
import AsyncSelect from 'react-select/async'; import AsyncSelect from 'react-select/async';
import { NonceProvider } from 'react-select';
import SettingToggle from '../../notifications/components/setting_toggle'; import SettingToggle from '../../notifications/components/setting_toggle';
const messages = defineMessages({ const messages = defineMessages({
@ -58,18 +59,20 @@ class ColumnSettings extends React.PureComponent {
{this.modeLabel(mode)} {this.modeLabel(mode)}
</span> </span>
<AsyncSelect <NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content}>
isMulti <AsyncSelect
autoFocus isMulti
value={this.tags(mode)} autoFocus
onChange={this.onSelect(mode)} value={this.tags(mode)}
loadOptions={this.props.onLoad} onChange={this.onSelect(mode)}
className='column-select__container' loadOptions={this.props.onLoad}
classNamePrefix='column-select' className='column-select__container'
name='tags' classNamePrefix='column-select'
placeholder={this.props.intl.formatMessage(messages.placeholder)} name='tags'
noOptionsMessage={this.noOptionsMessage} placeholder={this.props.intl.formatMessage(messages.placeholder)}
/> noOptionsMessage={this.noOptionsMessage}
/>
</NonceProvider>
</div> </div>
); );
} }

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import classNames from 'classnames'; import classNames from 'classnames';
import { changeUploadCompose } from '../../../actions/compose'; import { changeUploadCompose, uploadThumbnail } from '../../../actions/compose';
import { getPointerPosition } from '../../video'; import { getPointerPosition } from '../../video';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
@ -23,11 +23,13 @@ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' }, apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' }, placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
}); });
const mapStateToProps = (state, { id }) => ({ const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
account: state.getIn(['accounts', me]), account: state.getIn(['accounts', me]),
isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
}); });
const mapDispatchToProps = (dispatch, { id }) => ({ const mapDispatchToProps = (dispatch, { id }) => ({
@ -36,6 +38,10 @@ const mapDispatchToProps = (dispatch, { id }) => ({
dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` })); dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
}, },
onSelectThumbnail: files => {
dispatch(uploadThumbnail(id, files[0]));
},
}); });
const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
@ -81,6 +87,9 @@ class FocalPointModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map.isRequired,
isUploadingThumbnail: PropTypes.bool,
onSave: PropTypes.func.isRequired,
onSelectThumbnail: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -235,13 +244,29 @@ class FocalPointModal extends ImmutablePureComponent {
}).catch(() => this.setState({ detecting: false })); }).catch(() => this.setState({ detecting: false }));
} }
handleThumbnailChange = e => {
if (e.target.files.length > 0) {
this.setState({ dirty: true });
this.props.onSelectThumbnail(e.target.files);
}
}
setFileInputRef = c => {
this.fileInput = c;
}
handleFileInputClick = () => {
this.fileInput.click();
}
render () { render () {
const { media, intl, account, onClose } = this.props; const { media, intl, account, onClose, isUploadingThumbnail } = this.props;
const { x, y, dragging, description, dirty, detecting, progress } = this.state; const { x, y, dragging, description, dirty, detecting, progress } = this.state;
const width = media.getIn(['meta', 'original', 'width']) || null; const width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || null; const height = media.getIn(['meta', 'original', 'height']) || null;
const focals = ['image', 'gifv'].includes(media.get('type')); const focals = ['image', 'gifv'].includes(media.get('type'));
const thumbnailable = ['audio', 'video'].includes(media.get('type'));
const previewRatio = 16/9; const previewRatio = 16/9;
const previewWidth = 200; const previewWidth = 200;
@ -268,6 +293,30 @@ class FocalPointModal extends ImmutablePureComponent {
<div className='report-modal__comment'> <div className='report-modal__comment'>
{focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>} {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
{thumbnailable && (
<React.Fragment>
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
<Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
<input
id='upload-modal__thumbnail'
ref={this.setFileInputRef}
type='file'
accept='image/png,image/jpeg'
onChange={this.handleThumbnailChange}
style={{ display: 'none' }}
disabled={isUploadingThumbnail}
/>
</label>
<hr className='setting-divider' />
</React.Fragment>
)}
<label className='setting-text-label' htmlFor='upload-modal__description'> <label className='setting-text-label' htmlFor='upload-modal__description'>
{descriptionLabel} {descriptionLabel}
</label> </label>
@ -293,7 +342,7 @@ class FocalPointModal extends ImmutablePureComponent {
<CharacterCounter max={1500} text={detecting ? '' : description} /> <CharacterCounter max={1500} text={detecting ? '' : description} />
</div> </div>
<Button disabled={!dirty || detecting || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} /> <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
</div> </div>
<div className='focal-point-modal__content'> <div className='focal-point-modal__content'>

View File

@ -666,24 +666,16 @@
{ {
"descriptors": [ "descriptors": [
{ {
"defaultMessage": "No comment provided", "defaultMessage": "Click to add a note",
"id": "account_note.placeholder" "id": "account_note.placeholder"
}, },
{ {
"defaultMessage": "Cancel", "defaultMessage": "Saved",
"id": "account_note.cancel" "id": "generic.saved"
}, },
{ {
"defaultMessage": "Save", "defaultMessage": "Note",
"id": "account_note.save"
},
{
"defaultMessage": "Your note for @{name}",
"id": "account.account_note_header" "id": "account.account_note_header"
},
{
"defaultMessage": "Edit",
"id": "account_note.edit"
} }
], ],
"path": "app/javascript/mastodon/features/account/components/account_note.json" "path": "app/javascript/mastodon/features/account/components/account_note.json"
@ -818,10 +810,6 @@
"defaultMessage": "Open moderation interface for @{name}", "defaultMessage": "Open moderation interface for @{name}",
"id": "status.admin_account" "id": "status.admin_account"
}, },
{
"defaultMessage": "Add note for @{name}",
"id": "account.add_account_note"
},
{ {
"defaultMessage": "Follows you", "defaultMessage": "Follows you",
"id": "account.follows_you" "id": "account.follows_you"

View File

@ -1,6 +1,5 @@
{ {
"account.account_note_header": "Your note for @{name}", "account.account_note_header": "Note",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Add or Remove from lists", "account.add_or_remove_from_list": "Add or Remove from lists",
"account.badges.bot": "Bot", "account.badges.bot": "Bot",
"account.badges.group": "Group", "account.badges.group": "Group",
@ -42,10 +41,7 @@
"account.unfollow": "Unfollow", "account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}", "account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}", "account.unmute_notifications": "Unmute notifications from @{name}",
"account_note.cancel": "Cancel", "account_note.placeholder": "Click to add note",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.", "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
"alert.rate_limited.title": "Rate limited", "alert.rate_limited.title": "Rate limited",
"alert.unexpected.message": "An unexpected error occurred.", "alert.unexpected.message": "An unexpected error occurred.",
@ -178,6 +174,7 @@
"follow_request.authorize": "Authorize", "follow_request.authorize": "Authorize",
"follow_request.reject": "Reject", "follow_request.reject": "Reject",
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.", "follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
"generic.saved": "Saved",
"getting_started.developers": "Developers", "getting_started.developers": "Developers",
"getting_started.directory": "Profile directory", "getting_started.directory": "Profile directory",
"getting_started.documentation": "Documentation", "getting_started.documentation": "Documentation",
@ -377,7 +374,7 @@
"status.bookmark": "Bookmark", "status.bookmark": "Bookmark",
"status.cancel_reblog_private": "Unboost", "status.cancel_reblog_private": "Unboost",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.copy": "Copy link to status", "status.copy": "Copy link to toot",
"status.delete": "Delete", "status.delete": "Delete",
"status.detailed_status": "Detailed conversation view", "status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}", "status.direct": "Direct message @{name}",

View File

@ -1,44 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import {
ACCOUNT_NOTE_INIT_EDIT,
ACCOUNT_NOTE_CANCEL,
ACCOUNT_NOTE_CHANGE_COMMENT,
ACCOUNT_NOTE_SUBMIT_REQUEST,
ACCOUNT_NOTE_SUBMIT_FAIL,
ACCOUNT_NOTE_SUBMIT_SUCCESS,
} from '../actions/account_notes';
const initialState = ImmutableMap({
edit: ImmutableMap({
isSubmitting: false,
account_id: null,
comment: null,
}),
});
export default function account_notes(state = initialState, action) {
switch (action.type) {
case ACCOUNT_NOTE_INIT_EDIT:
return state.withMutations((state) => {
state.setIn(['edit', 'isSubmitting'], false);
state.setIn(['edit', 'account_id'], action.account.get('id'));
state.setIn(['edit', 'comment'], action.comment);
});
case ACCOUNT_NOTE_CHANGE_COMMENT:
return state.setIn(['edit', 'comment'], action.comment);
case ACCOUNT_NOTE_SUBMIT_REQUEST:
return state.setIn(['edit', 'isSubmitting'], true);
case ACCOUNT_NOTE_SUBMIT_FAIL:
return state.setIn(['edit', 'isSubmitting'], false);
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
case ACCOUNT_NOTE_CANCEL:
return state.withMutations((state) => {
state.setIn(['edit', 'isSubmitting'], false);
state.setIn(['edit', 'account_id'], null);
state.setIn(['edit', 'comment'], null);
});
default:
return state;
}
}

View File

@ -14,6 +14,10 @@ import {
COMPOSE_UPLOAD_FAIL, COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_UNDO, COMPOSE_UPLOAD_UNDO,
COMPOSE_UPLOAD_PROGRESS, COMPOSE_UPLOAD_PROGRESS,
THUMBNAIL_UPLOAD_REQUEST,
THUMBNAIL_UPLOAD_SUCCESS,
THUMBNAIL_UPLOAD_FAIL,
THUMBNAIL_UPLOAD_PROGRESS,
COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT, COMPOSE_SUGGESTION_SELECT,
@ -60,6 +64,8 @@ const initialState = ImmutableMap({
is_changing_upload: false, is_changing_upload: false,
is_uploading: false, is_uploading: false,
progress: 0, progress: 0,
isUploadingThumbnail: false,
thumbnailProgress: 0,
media_attachments: ImmutableList(), media_attachments: ImmutableList(),
pending_media_attachments: 0, pending_media_attachments: 0,
poll: null, poll: null,
@ -332,6 +338,22 @@ export default function compose(state = initialState, action) {
return removeMedia(state, action.media_id); return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS: case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100)); return state.set('progress', Math.round((action.loaded / action.total) * 100));
case THUMBNAIL_UPLOAD_REQUEST:
return state.set('isUploadingThumbnail', true);
case THUMBNAIL_UPLOAD_PROGRESS:
return state.set('thumbnailProgress', Math.round((action.loaded / action.total) * 100));
case THUMBNAIL_UPLOAD_FAIL:
return state.set('isUploadingThumbnail', false);
case THUMBNAIL_UPLOAD_SUCCESS:
return state
.set('isUploadingThumbnail', false)
.update('media_attachments', list => list.map(item => {
if (item.get('id') === action.media.id) {
return fromJS(action.media);
}
return item;
}));
case COMPOSE_MENTION: case COMPOSE_MENTION:
return state.withMutations(map => { return state.withMutations(map => {
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));

View File

@ -36,7 +36,6 @@ import trends from './trends';
import missed_updates from './missed_updates'; import missed_updates from './missed_updates';
import announcements from './announcements'; import announcements from './announcements';
import markers from './markers'; import markers from './markers';
import account_notes from './account_notes';
const reducers = { const reducers = {
announcements, announcements,
@ -76,7 +75,6 @@ const reducers = {
trends, trends,
missed_updates, missed_updates,
markers, markers,
account_notes,
}; };
export default combineReducers(reducers); export default combineReducers(reducers);

View File

@ -1,16 +1,71 @@
import React, { Fragment } from 'react'; // @ts-check
import { FormattedNumber } from 'react-intl';
export const shortNumberFormat = number => { export const DECIMAL_UNITS = Object.freeze({
if (number < 1000) { ONE: 1,
return <FormattedNumber value={number} />; TEN: 10,
} else if (number < 10000) { HUNDRED: Math.pow(10, 2),
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>; THOUSAND: Math.pow(10, 3),
} else if (number < 1000000) { MILLION: Math.pow(10, 6),
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={0} />K</Fragment>; BILLION: Math.pow(10, 9),
} else if (number < 10000000) { TRILLION: Math.pow(10, 12),
return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={1} />M</Fragment>; });
} else {
return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={0} />M</Fragment>; const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10;
const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
/**
* @typedef {[number, number, number]} ShortNumber
* Array of: shorten number, unit of shorten number and maximum fraction digits
*/
/**
* @param {number} sourceNumber Number to convert to short number
* @returns {ShortNumber} Calculated short number
* @example
* shortNumber(5936);
* // => [5.936, 1000, 1]
*/
export function toShortNumber(sourceNumber) {
if (sourceNumber < DECIMAL_UNITS.THOUSAND) {
return [sourceNumber, DECIMAL_UNITS.ONE, 0];
} else if (sourceNumber < DECIMAL_UNITS.MILLION) {
return [
sourceNumber / DECIMAL_UNITS.THOUSAND,
DECIMAL_UNITS.THOUSAND,
sourceNumber < TEN_THOUSAND ? 1 : 0,
];
} else if (sourceNumber < DECIMAL_UNITS.BILLION) {
return [
sourceNumber / DECIMAL_UNITS.MILLION,
DECIMAL_UNITS.MILLION,
sourceNumber < TEN_MILLIONS ? 1 : 0,
];
} else if (sourceNumber < DECIMAL_UNITS.TRILLION) {
return [
sourceNumber / DECIMAL_UNITS.BILLION,
DECIMAL_UNITS.BILLION,
0,
];
} }
};
return [sourceNumber, DECIMAL_UNITS.ONE, 0];
}
/**
* @param {number} sourceNumber Original number that is shortened
* @param {number} division The scale in which short number is displayed
* @returns {number} Number that can be used for plurals when short form used
* @example
* pluralReady(1793, DECIMAL_UNITS.THOUSAND)
* // => 1790
*/
export function pluralReady(sourceNumber, division) {
// eslint-disable-next-line eqeqeq
if (division == null || division < DECIMAL_UNITS.HUNDRED) {
return sourceNumber;
}
let closestScale = division / DECIMAL_UNITS.TEN;
return Math.trunc(sourceNumber / closestScale) * closestScale;
}

View File

@ -11,6 +11,15 @@
position: relative; position: relative;
} }
.inline-alert {
color: $valid-value-color;
font-weight: 400;
.no-reduce-motion & {
transition: opacity 200ms ease;
}
}
.link-button { .link-button {
display: block; display: block;
font-size: 15px; font-size: 15px;
@ -4868,6 +4877,15 @@ a.status-card.compact:hover {
} }
} }
.setting-divider {
background: transparent;
border: 0;
margin: 0;
width: 100%;
height: 1px;
margin-bottom: 29px;
}
.report-modal__comment { .report-modal__comment {
padding: 20px; padding: 20px;
border-right: 1px solid $ui-secondary-color; border-right: 1px solid $ui-secondary-color;
@ -6557,6 +6575,11 @@ noscript {
padding: 20px 15px; padding: 20px 15px;
padding-bottom: 5px; padding-bottom: 5px;
color: $primary-text-color; color: $primary-text-color;
.columns-area--mobile & {
padding-left: 20px;
padding-right: 20px;
}
} }
.account__header__fields { .account__header__fields {
@ -6601,63 +6624,50 @@ noscript {
} }
&__account-note { &__account-note {
margin: 5px; padding: 15px;
padding: 10px; padding-bottom: 10px;
background: $ui-highlight-color;
color: $primary-text-color; color: $primary-text-color;
display: flex;
flex-direction: column;
border-radius: 4px;
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
border-bottom: 1px solid lighten($ui-base-color, 12%);
&__header { .columns-area--mobile & {
display: flex; padding-left: 20px;
flex-direction: row; padding-right: 20px;
justify-content: space-between;
} }
&__content { label {
white-space: pre-wrap; display: block;
margin-top: 5px; font-size: 12px;
}
&__buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: 5px;
.flex-spacer {
flex: 0 0 20px;
background: transparent;
}
}
strong {
font-size: 15px;
font-weight: 500; font-weight: 500;
} color: $darker-text-color;
text-transform: uppercase;
button:hover span { margin-bottom: 5px;
text-decoration: underline;
} }
textarea { textarea {
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: calc(100% + 20px);
margin: 0; color: $secondary-text-color;
margin-top: 5px; background: transparent;
color: $inverted-text-color;
background: $simple-background-color;
padding: 10px; padding: 10px;
margin: 0 -10px;
font-family: inherit; font-family: inherit;
font-size: 14px; font-size: 14px;
resize: none; resize: none;
border: 0; border: 0;
outline: 0; outline: 0;
border-radius: 4px; border-radius: 4px;
&::placeholder {
color: $dark-text-color;
opacity: 1;
}
&:focus {
background: $ui-base-color;
}
} }
} }
} }

View File

@ -52,6 +52,6 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
end end
def note def note
(instance_options[:relationships].account_note[object.id] || {})[:comment] (instance_options[:relationships].account_note[object.id] || {})[:comment] || ''
end end
end end

View File

@ -45,7 +45,7 @@ class FetchLinkCardService < BaseService
def html def html
return @html if defined?(@html) return @html if defined?(@html)
Request.new(:get, @url).add_headers('Accept' => 'text/html').perform do |res| Request.new(:get, @url).add_headers('Accept' => 'text/html', 'User-Agent' => Mastodon::Version.user_agent + ' Bot').perform do |res|
if res.code == 200 && res.mime_type == 'text/html' if res.code == 200 && res.mime_type == 'text/html'
@html = res.body_with_limit @html = res.body_with_limit
@html_charset = res.charset @html_charset = res.charset

View File

@ -27,6 +27,7 @@
- elsif @theme[:supported_locales].include? 'en' - elsif @theme[:supported_locales].include? 'en'
= javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous' = javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous'
= csrf_meta_tags = csrf_meta_tags
%meta{ name: 'style-nonce', content: request.content_security_policy_nonce }
= stylesheet_link_tag '/inert.css', skip_pipeline: true, media: 'all', id: 'inert-style' = stylesheet_link_tag '/inert.css', skip_pipeline: true, media: 'all', id: 'inert-style'

View File

@ -104,12 +104,12 @@ persistence:
accessMode: ReadWriteOnce accessMode: ReadWriteOnce
resources: resources:
requests: requests:
storage: 100Gi storage: 10Gi
system: system:
accessMode: ReadWriteOnce accessMode: ReadWriteOnce
resources: resources:
requests: requests:
storage: 10Gi storage: 100Gi
service: service:
type: ClusterIP type: ClusterIP

View File

@ -49,7 +49,25 @@ end
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
# Rails.application.config.content_security_policy_report_only = true # Rails.application.config.content_security_policy_report_only = true
Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
# Monkey-patching Rails 5
module ActionDispatch
class ContentSecurityPolicy
def nonce_directive?(directive)
directive == 'style-src'
end
end
end
# Rails 6 would require the following instead:
# Rails.application.config.content_security_policy_nonce_directives = %w(style-src)
PgHero::HomeController.content_security_policy do |p| PgHero::HomeController.content_security_policy do |p|
p.script_src :self, :unsafe_inline, assets_host p.script_src :self, :unsafe_inline, assets_host
p.style_src :self, :unsafe_inline, assets_host p.style_src :self, :unsafe_inline, assets_host
end end
PgHero::HomeController.after_action do
request.content_security_policy_nonce_generator = nil
end

View File

@ -38,15 +38,6 @@ class Rack::Attack
end end
end end
PROTECTED_PATHS = %w(
/auth/sign_in
/auth
/auth/password
/auth/confirmation
).freeze
PROTECTED_PATHS_REGEX = Regexp.union(PROTECTED_PATHS.map { |path| /\A#{Regexp.escape(path)}/ })
Rack::Attack.safelist('allow from localhost') do |req| Rack::Attack.safelist('allow from localhost') do |req|
req.remote_ip == '127.0.0.1' || req.remote_ip == '::1' req.remote_ip == '127.0.0.1' || req.remote_ip == '::1'
end end
@ -86,8 +77,32 @@ class Rack::Attack
req.authenticated_user_id if (req.post? && req.path =~ API_DELETE_REBLOG_REGEX) || (req.delete? && req.path =~ API_DELETE_STATUS_REGEX) req.authenticated_user_id if (req.post? && req.path =~ API_DELETE_REBLOG_REGEX) || (req.delete? && req.path =~ API_DELETE_STATUS_REGEX)
end end
throttle('protected_paths', limit: 25, period: 5.minutes) do |req| throttle('throttle_sign_up_attempts/ip', limit: 25, period: 5.minutes) do |req|
req.remote_ip if req.post? && req.path =~ PROTECTED_PATHS_REGEX req.remote_ip if req.post? && req.path == '/auth'
end
throttle('throttle_password_resets/ip', limit: 25, period: 5.minutes) do |req|
req.remote_ip if req.post? && req.path == '/auth/password'
end
throttle('throttle_password_resets/email', limit: 5, period: 30.minutes) do |req|
req.params.dig('user', 'email').presence if req.post? && req.path == '/auth/password'
end
throttle('throttle_email_confirmations/ip', limit: 25, period: 5.minutes) do |req|
req.remote_ip if req.post? && req.path == '/auth/confirmation'
end
throttle('throttle_email_confirmations/email', limit: 5, period: 30.minutes) do |req|
req.params.dig('user', 'email').presence if req.post? && req.path == '/auth/password'
end
throttle('throttle_login_attempts/ip', limit: 25, period: 5.minutes) do |req|
req.remote_ip if req.post? && req.path == '/auth/sign_in'
end
throttle('throttle_login_attempts/email', limit: 25, period: 1.hour) do |req|
req.session[:attempt_user_id] || req.params.dig('user', 'email').presence if req.post? && req.path == '/auth/sign_in'
end end
self.throttled_response = lambda do |env| self.throttled_response = lambda do |env|

View File

@ -2,5 +2,6 @@ ActiveSupport::Notifications.subscribe(/rack_attack/) do |_name, _start, _finish
req = payload[:request] req = payload[:request]
next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type'] next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}") Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
end end

View File

@ -0,0 +1,17 @@
class MediaAttachmentIdsToTimestampIds < ActiveRecord::Migration[5.1]
def up
# Set up the media_attachments.id column to use our timestamp-based IDs.
safety_assured do
execute("ALTER TABLE media_attachments ALTER COLUMN id SET DEFAULT timestamp_id('media_attachments')")
end
# Make sure we have a sequence to use.
Mastodon::Snowflake.ensure_id_sequences_exist
end
def down
execute("LOCK media_attachments")
execute("SELECT setval('media_attachments_id_seq', (SELECT MAX(id) FROM media_attachments))")
execute("ALTER TABLE media_attachments ALTER COLUMN id SET DEFAULT nextval('media_attachments_id_seq')")
end
end

View File

@ -77,6 +77,16 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
t.index ["target_account_id"], name: "index_account_moderation_notes_on_target_account_id" t.index ["target_account_id"], name: "index_account_moderation_notes_on_target_account_id"
end end
create_table "account_notes", force: :cascade do |t|
t.bigint "account_id"
t.bigint "target_account_id"
t.text "comment", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "target_account_id"], name: "index_account_notes_on_account_id_and_target_account_id", unique: true
t.index ["target_account_id"], name: "index_account_notes_on_target_account_id"
end
create_table "account_pins", force: :cascade do |t| create_table "account_pins", force: :cascade do |t|
t.bigint "account_id" t.bigint "account_id"
t.bigint "target_account_id" t.bigint "target_account_id"
@ -472,7 +482,7 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
t.index ["user_id", "timeline"], name: "index_markers_on_user_id_and_timeline", unique: true t.index ["user_id", "timeline"], name: "index_markers_on_user_id_and_timeline", unique: true
end end
create_table "media_attachments", force: :cascade do |t| create_table "media_attachments", id: :bigint, default: -> { "timestamp_id('media_attachments'::text)" }, force: :cascade do |t|
t.bigint "status_id" t.bigint "status_id"
t.string "file_file_name" t.string "file_file_name"
t.string "file_content_type" t.string "file_content_type"
@ -836,16 +846,6 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
t.index ["user_id"], name: "index_user_invite_requests_on_user_id" t.index ["user_id"], name: "index_user_invite_requests_on_user_id"
end end
create_table "account_notes", force: :cascade do |t|
t.bigint "account_id"
t.bigint "target_account_id"
t.text "comment", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "target_account_id"], name: "index_account_notes_on_account_id_and_target_account_id", unique: true
t.index ["target_account_id"], name: "index_account_notes_on_target_account_id"
end
create_table "users", force: :cascade do |t| create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false t.string "email", default: "", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@ -921,6 +921,8 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
add_foreign_key "account_migrations", "accounts", on_delete: :cascade add_foreign_key "account_migrations", "accounts", on_delete: :cascade
add_foreign_key "account_moderation_notes", "accounts" add_foreign_key "account_moderation_notes", "accounts"
add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id" add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id"
add_foreign_key "account_notes", "accounts", column: "target_account_id", on_delete: :cascade
add_foreign_key "account_notes", "accounts", on_delete: :cascade
add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade
add_foreign_key "account_pins", "accounts", on_delete: :cascade add_foreign_key "account_pins", "accounts", on_delete: :cascade
add_foreign_key "account_stats", "accounts", on_delete: :cascade add_foreign_key "account_stats", "accounts", on_delete: :cascade
@ -1002,8 +1004,6 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
add_foreign_key "statuses_tags", "tags", name: "fk_3081861e21", on_delete: :cascade add_foreign_key "statuses_tags", "tags", name: "fk_3081861e21", on_delete: :cascade
add_foreign_key "tombstones", "accounts", on_delete: :cascade add_foreign_key "tombstones", "accounts", on_delete: :cascade
add_foreign_key "user_invite_requests", "users", on_delete: :cascade add_foreign_key "user_invite_requests", "users", on_delete: :cascade
add_foreign_key "account_notes", "accounts", column: "target_account_id", on_delete: :cascade
add_foreign_key "account_notes", "accounts", on_delete: :cascade
add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade
add_foreign_key "users", "invites", on_delete: :nullify add_foreign_key "users", "invites", on_delete: :nullify
add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify

View File

@ -63,17 +63,17 @@
"@babel/core": "^7.10.3", "@babel/core": "^7.10.3",
"@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-decorators": "^7.10.3", "@babel/plugin-proposal-decorators": "^7.10.3",
"@babel/plugin-transform-react-inline-elements": "^7.10.1", "@babel/plugin-transform-react-inline-elements": "^7.10.4",
"@babel/plugin-transform-runtime": "^7.10.3", "@babel/plugin-transform-runtime": "^7.10.4",
"@babel/preset-env": "^7.10.2", "@babel/preset-env": "^7.10.4",
"@babel/preset-react": "^7.10.1", "@babel/preset-react": "^7.10.4",
"@babel/runtime": "^7.8.4", "@babel/runtime": "^7.8.4",
"@clusterws/cws": "^2.0.0", "@clusterws/cws": "^2.0.0",
"@gamestdio/websocket": "^0.3.2", "@gamestdio/websocket": "^0.3.2",
"@rails/ujs": "^6.0.3", "@rails/ujs": "^6.0.3",
"array-includes": "^3.1.1", "array-includes": "^3.1.1",
"arrow-key-navigation": "^1.1.0",
"atrament": "0.2.4", "atrament": "0.2.4",
"arrow-key-navigation": "^1.2.0",
"autoprefixer": "^9.8.0", "autoprefixer": "^9.8.0",
"axios": "^0.19.2", "axios": "^0.19.2",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
@ -159,7 +159,7 @@
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"stringz": "^2.1.0", "stringz": "^2.1.0",
"substring-trie": "^1.0.2", "substring-trie": "^1.0.2",
"terser-webpack-plugin": "^3.0.3", "terser-webpack-plugin": "^3.0.6",
"tesseract.js": "^2.1.1", "tesseract.js": "^2.1.1",
"throng": "^4.0.0", "throng": "^4.0.0",
"tiny-queue": "^0.2.1", "tiny-queue": "^0.2.1",
@ -175,7 +175,7 @@
"@testing-library/jest-dom": "^5.11.0", "@testing-library/jest-dom": "^5.11.0",
"@testing-library/react": "^10.4.3", "@testing-library/react": "^10.4.3",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^25.2.4", "babel-jest": "^26.1.0",
"eslint": "^6.8.0", "eslint": "^6.8.0",
"eslint-plugin-import": "~2.21.2", "eslint-plugin-import": "~2.21.2",
"eslint-plugin-jsx-a11y": "~6.3.1", "eslint-plugin-jsx-a11y": "~6.3.1",
@ -187,7 +187,7 @@
"react-test-renderer": "^16.13.1", "react-test-renderer": "^16.13.1",
"sass-lint": "^1.13.1", "sass-lint": "^1.13.1",
"webpack-dev-server": "^3.11.0", "webpack-dev-server": "^3.11.0",
"yargs": "^15.3.1" "yargs": "^15.4.0"
}, },
"resolutions": { "resolutions": {
"kind-of": "^6.0.3" "kind-of": "^6.0.3"

View File

@ -28,9 +28,8 @@ describe MediaController do
end end
it 'raises when not permitted to view' do it 'raises when not permitted to view' do
status = Fabricate(:status) status = Fabricate(:status, visibility: :direct)
media_attachment = Fabricate(:media_attachment, status: status) media_attachment = Fabricate(:media_attachment, status: status)
allow_any_instance_of(MediaController).to receive(:authorize).and_raise(ActiveRecord::RecordNotFound)
get :show, params: { id: media_attachment.to_param } get :show, params: { id: media_attachment.to_param }
expect(response).to have_http_status(404) expect(response).to have_http_status(404)

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
require 'rails_helper'
describe MediaProxyController do
render_views
before do
stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
end
describe '#show' do
it 'redirects when attached to a status' do
status = Fabricate(:status)
media_attachment = Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png')
get :show, params: { id: media_attachment.id }
expect(response).to have_http_status(302)
end
it 'responds with missing when there is not an attached status' do
media_attachment = Fabricate(:media_attachment, status: nil, remote_url: 'http://example.com/attachment.png')
get :show, params: { id: media_attachment.id }
expect(response).to have_http_status(404)
end
it 'raises when id cant be found' do
get :show, params: { id: 'missing' }
expect(response).to have_http_status(404)
end
it 'raises when not permitted to view' do
status = Fabricate(:status, visibility: :direct)
media_attachment = Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png')
get :show, params: { id: media_attachment.id }
expect(response).to have_http_status(404)
end
end
end

View File

@ -20,6 +20,7 @@ RSpec.describe SuspendAccountService, type: :service do
let!(:passive_relationship) { Fabricate(:follow, target_account: account) } let!(:passive_relationship) { Fabricate(:follow, target_account: account) }
let!(:remote_alice) { Fabricate(:account, inbox_url: 'https://alice.com/inbox', protocol: :activitypub) } let!(:remote_alice) { Fabricate(:account, inbox_url: 'https://alice.com/inbox', protocol: :activitypub) }
let!(:remote_bob) { Fabricate(:account, inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } let!(:remote_bob) { Fabricate(:account, inbox_url: 'https://bob.com/inbox', protocol: :activitypub) }
let!(:endorsment) { Fabricate(:account_pin, account: passive_relationship.account, target_account: account) }
it 'deletes associated records' do it 'deletes associated records' do
is_expected.to change { is_expected.to change {
@ -30,8 +31,9 @@ RSpec.describe SuspendAccountService, type: :service do
account.favourites, account.favourites,
account.active_relationships, account.active_relationships,
account.passive_relationships, account.passive_relationships,
AccountPin.where(target_account: account),
].map(&:count) ].map(&:count)
}.from([1, 1, 1, 1, 1, 1]).to([0, 0, 0, 0, 0, 0]) }.from([1, 1, 1, 1, 1, 1, 1]).to([0, 0, 0, 0, 0, 0, 0])
end end
it 'sends a delete actor activity to all known inboxes' do it 'sends a delete actor activity to all known inboxes' do

1778
yarn.lock

File diff suppressed because it is too large Load Diff