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

Merge upstream changes
local
ThibG 4 years ago committed by GitHub
commit c4e1b82caf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      Gemfile
  2. 20
      Gemfile.lock
  3. 5
      app/controllers/auth/passwords_controller.rb
  4. 8
      app/controllers/auth/registrations_controller.rb
  5. 4
      app/controllers/home_controller.rb
  6. 5
      app/controllers/media_proxy_controller.rb
  7. 49
      app/javascript/flavours/glitch/actions/compose.js
  8. 27
      app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
  9. 55
      app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
  10. 22
      app/javascript/flavours/glitch/reducers/compose.js
  11. 9
      app/javascript/flavours/glitch/styles/components/modal.scss
  12. 36
      app/javascript/mastodon/actions/account_notes.js
  13. 49
      app/javascript/mastodon/actions/compose.js
  14. 24
      app/javascript/mastodon/components/autosuggest_hashtag.js
  15. 62
      app/javascript/mastodon/components/common_counter.js
  16. 49
      app/javascript/mastodon/components/hashtag.js
  17. 117
      app/javascript/mastodon/components/short_number.js
  18. 183
      app/javascript/mastodon/features/account/components/account_note.js
  19. 29
      app/javascript/mastodon/features/account/components/header.js
  20. 29
      app/javascript/mastodon/features/account/containers/account_note_container.js
  21. 5
      app/javascript/mastodon/features/account_timeline/containers/header_container.js
  22. 171
      app/javascript/mastodon/features/directory/components/account_card.js
  23. 27
      app/javascript/mastodon/features/hashtag_timeline/components/column_settings.js
  24. 55
      app/javascript/mastodon/features/ui/components/focal_point_modal.js
  25. 20
      app/javascript/mastodon/locales/defaultMessages.json
  26. 11
      app/javascript/mastodon/locales/en.json
  27. 44
      app/javascript/mastodon/reducers/account_notes.js
  28. 22
      app/javascript/mastodon/reducers/compose.js
  29. 2
      app/javascript/mastodon/reducers/index.js
  30. 85
      app/javascript/mastodon/utils/numbers.js
  31. 86
      app/javascript/styles/mastodon/components.scss
  32. 2
      app/serializers/rest/relationship_serializer.rb
  33. 2
      app/services/fetch_link_card_service.rb
  34. 1
      app/views/layouts/application.html.haml
  35. 4
      chart/values.yaml.template
  36. 18
      config/initializers/content_security_policy.rb
  37. 37
      config/initializers/rack_attack.rb
  38. 1
      config/initializers/rack_attack_logging.rb
  39. 17
      db/migrate/20200622213645_media_attachment_ids_to_timestamp_ids.rb
  40. 26
      db/schema.rb
  41. 16
      package.json
  42. 3
      spec/controllers/media_controller_spec.rb
  43. 42
      spec/controllers/media_proxy_controller_spec.rb
  44. 4
      spec/services/suspend_account_service_spec.rb
  45. 1812
      yarn.lock

@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.5'
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-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'

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

@ -9,7 +9,10 @@ class Auth::PasswordsController < Devise::PasswordsController
def update
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

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Auth::RegistrationsController < Devise::RegistrationsController
include Devise::Controllers::Rememberable
layout :determine_layout
before_action :set_invite, only: [:new, :create]
@ -25,7 +27,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def update
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

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

@ -2,6 +2,7 @@
class MediaProxyController < ApplicationController
include RoutingHelper
include Authorization
skip_before_action :store_current_location
skip_before_action :require_functional!
@ -10,12 +11,14 @@ class MediaProxyController < ApplicationController
rescue_from ActiveRecord::RecordInvalid, 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
def show
RedisLock.acquire(lock_options) do |lock|
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?
else
raise Mastodon::RaceConditionError

@ -30,6 +30,11 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
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_READY = 'COMPOSE_SUGGESTIONS_READY';
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
@ -289,6 +294,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) {
return (dispatch, getState) => {
dispatch(changeUploadComposeRequest());
@ -307,6 +355,7 @@ export function changeUploadComposeRequest() {
skipLoading: true,
};
};
export function changeUploadComposeSuccess(media) {
return {
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,

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

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { changeUploadCompose } from 'flavours/glitch/actions/compose';
import { changeUploadCompose, uploadThumbnail } from 'flavours/glitch/actions/compose';
import { getPointerPosition } from 'flavours/glitch/features/video';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'flavours/glitch/components/icon_button';
@ -23,11 +23,13 @@ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
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 }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
account: state.getIn(['accounts', me]),
isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
});
const mapDispatchToProps = (dispatch, { id }) => ({
@ -36,6 +38,10 @@ const mapDispatchToProps = (dispatch, { id }) => ({
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, '******')
@ -81,6 +87,9 @@ class FocalPointModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.map.isRequired,
isUploadingThumbnail: PropTypes.bool,
onSave: PropTypes.func.isRequired,
onSelectThumbnail: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -235,13 +244,29 @@ class FocalPointModal extends ImmutablePureComponent {
}).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 () {
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 width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || null;
const focals = ['image', 'gifv'].includes(media.get('type'));
const thumbnailable = ['audio', 'video'].includes(media.get('type'));
const previewRatio = 16/9;
const previewWidth = 200;
@ -268,6 +293,30 @@ class FocalPointModal extends ImmutablePureComponent {
<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>}
{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'>
{descriptionLabel}
</label>
@ -293,7 +342,7 @@ class FocalPointModal extends ImmutablePureComponent {
<CharacterCounter max={1500} text={detecting ? '' : description} />
</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 className='focal-point-modal__content'>

@ -15,6 +15,10 @@ import {
COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_UNDO,
COMPOSE_UPLOAD_PROGRESS,
THUMBNAIL_UPLOAD_REQUEST,
THUMBNAIL_UPLOAD_SUCCESS,
THUMBNAIL_UPLOAD_FAIL,
THUMBNAIL_UPLOAD_PROGRESS,
COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT,
@ -77,6 +81,8 @@ const initialState = ImmutableMap({
is_uploading: false,
is_changing_upload: false,
progress: 0,
isUploadingThumbnail: false,
thumbnailProgress: 0,
media_attachments: ImmutableList(),
pending_media_attachments: 0,
poll: null,
@ -433,6 +439,22 @@ export default function compose(state = initialState, action) {
return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS:
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:
return state.withMutations(map => {
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));

@ -555,6 +555,15 @@
}
}
.setting-divider {
background: transparent;
border: 0;
margin: 0;
width: 100%;
height: 1px;
margin-bottom: 29px;
}
.report-modal__comment {
padding: 20px;
border-right: 1px solid $ui-secondary-color;

@ -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_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL';
export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
export function submitAccountNote() {
export function submitAccountNote(id, value) {
return (dispatch, getState) => {
dispatch(submitAccountNoteRequest());
const id = getState().getIn(['account_notes', 'edit', 'account_id']);
api(getState).post(`/api/v1/accounts/${id}/note`, {
comment: getState().getIn(['account_notes', 'edit', 'comment']),
comment: value,
}).then(response => {
dispatch(submitAccountNoteSuccess(response.data));
}).catch(error => dispatch(submitAccountNoteFail(error)));
@ -42,28 +35,3 @@ export function submitAccountNoteFail(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,
};
};

@ -28,6 +28,11 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
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_READY = 'COMPOSE_SUGGESTIONS_READY';
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) {
return (dispatch, getState) => {
dispatch(changeUploadComposeRequest());
@ -278,6 +326,7 @@ export function changeUploadComposeRequest() {
skipLoading: true,
};
};
export function changeUploadComposeSuccess(media) {
return {
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,

@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { shortNumberFormat } from 'mastodon/utils/numbers';
import ShortNumber from 'mastodon/components/short_number';
import { FormattedMessage } from 'react-intl';
export default class AutosuggestHashtag extends React.PureComponent {
@ -13,14 +13,28 @@ export default class AutosuggestHashtag extends React.PureComponent {
}).isRequired,
};
render () {
render() {
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 (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>#<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 className='autosuggest-hashtag__name'>
#<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>
);
}

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

@ -1,26 +1,65 @@
// @ts-check
import React from 'react';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
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 }) => (
<div className='trends__item'>
<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>
</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 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 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' }} />
</Sparklines>
</div>

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

@ -3,99 +3,166 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'mastodon/components/icon';
import Textarea from 'react-textarea-autosize';
import { is } from 'immutable';
const messages = defineMessages({
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
});
class InlineAlert extends React.PureComponent {
static propTypes = {
show: PropTypes.bool,
};
state = {
mountMessage: false,
};
static TRANSITION_DELAY = 200;
componentWillReceiveProps (nextProps) {
if (!this.props.show && nextProps.show) {
this.setState({ mountMessage: true });
} else if (this.props.show && !nextProps.show) {
setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
}
}
render () {
const { show } = this.props;
const { mountMessage } = this.state;
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 Header extends ImmutablePureComponent {
class AccountNote extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
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,
value: PropTypes.string,
onSave: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleChangeAccountNote = (e) => {
this.props.onChangeAccountNote(e.target.value);
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.props.isEditing) {
this.props.onCancelAccountNote();
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)) {
this.props.onSaveAccountNote();
e.preventDefault();
this._save();
if (this.textarea) {
this.textarea.blur();
}
} else if (e.keyCode === 27) {
this.props.onCancelAccountNote();
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, accountNote, isEditing, isSubmitting, intl } = this.props;
const { account, intl } = this.props;
const { value, saved } = this.state;
if (!account || (!accountNote && !isEditing)) {
if (!account) {
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>
);
}
return (
<div className='account__header__account-note'>
<label htmlFor={`account-note-${account.get('id')}`}>
<FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
</label>
let note_container = null;
if (isEditing) {
note_container = (
<Textarea
id={`account-note-${account.get('id')}`}
className='account__header__account-note__content'
disabled={isSubmitting}
disabled={this.props.value === null || value === null}
placeholder={intl.formatMessage(messages.placeholder)}
value={accountNote}
onChange={this.handleChangeAccountNote}
value={value || ''}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
autoFocus
onBlur={this.handleBlur}
ref={this.setTextareaRef}
/>
);
} else {
note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
}
return (
<div className='account__header__account-note'>
<div className='account__header__account-note__header'>
<strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong>
{!isEditing && (
<div>
<button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
<Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
</button>
</div>
)}
</div>
{note_container}
{action_buttons}
</div>
);
}

@ -8,7 +8,8 @@ import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
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 DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import AccountNoteContainer from '../containers/account_note_container';
@ -66,7 +67,6 @@ class Header extends ImmutablePureComponent {
identity_props: ImmutablePropTypes.list,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onEditAccountNote: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired,
};
@ -131,8 +131,6 @@ class Header extends ImmutablePureComponent {
return null;
}
const accountNote = account.getIn(['relationship', 'note']);
let info = [];
let actionBtn = '';
let lockedIcon = '';
@ -183,10 +181,6 @@ class Header extends ImmutablePureComponent {
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) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
@ -293,8 +287,6 @@ class Header extends ImmutablePureComponent {
</h1>
</div>
<AccountNoteContainer account={account} />
<div className='account__header__extra'>
<div className='account__header__bio'>
{ (fields.size > 0 || identity_proofs.size > 0) && (
@ -323,20 +315,31 @@ class Header extends ImmutablePureComponent {
</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} />}
</div>
<div className='account__header__extra__links'>
<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 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 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>
</div>
</div>

@ -1,34 +1,17 @@
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';
const mapStateToProps = (state, { account }) => {
const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
return {
isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
isEditing,
};
};
const mapStateToProps = (state, { account }) => ({
value: account.getIn(['relationship', 'note']),
});
const mapDispatchToProps = (dispatch, { account }) => ({
onEditAccountNote() {
dispatch(initEditAccountNote(account));
},
onSaveAccountNote() {
dispatch(submitAccountNote());
onSave (value) {
dispatch(submitAccountNote(account.get('id'), value));
},
onCancelAccountNote() {
dispatch(cancelAccountNote());
},
onChangeAccountNote(comment) {
dispatch(changeAccountNoteComment(comment));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);

@ -19,7 +19,6 @@ import { initBlockModal } from '../../../actions/blocks';
import { initReport } from '../../../actions/reports';
import { openModal } from '../../../actions/modal';
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
import { initEditAccountNote } from 'mastodon/actions/account_notes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { unfollowModal } from '../../../initial_state';
import { List as ImmutableList } from 'immutable';
@ -103,10 +102,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onEditAccountNote (account) {
dispatch(initEditAccountNote(account));
},
onBlockDomain (domain) {
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> }} />,

@ -11,8 +11,14 @@ import RelativeTimestamp from 'mastodon/components/relative_timestamp';
import IconButton from 'mastodon/components/icon_button';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
import { shortNumberFormat } from 'mastodon/utils/numbers';
import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
import ShortNumber from 'mastodon/components/short_number';
import {
followAccount,
unfollowAccount,
blockAccount,
unblockAccount,
unmuteAccount,
} from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { initMuteModal } from 'mastodon/actions/mutes';
@ -22,7 +28,10 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
unfollowConfirm: {
id: 'confirmations.unfollow.confirm',
defaultMessage: 'Unfollow',
},
});
const makeMapStateToProps = () => {
@ -36,15 +45,25 @@ const makeMapStateToProps = () => {
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
onFollow(account) {
if (
account.getIn(['relationship', 'following']) ||
account.getIn(['relationship', 'requested'])
) {
if (unfollowModal) {
dispatch(openModal('CONFIRM', {
message: <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'))),
}));
dispatch(
openModal('CONFIRM', {
message: (
<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 {
dispatch(unfollowAccount(account.get('id')));
}
@ -53,7 +72,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onBlock (account) {
onBlock(account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
@ -61,17 +80,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onMute (account) {
onMute(account) {
if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id')));
} else {
dispatch(initMuteModal(account));
}
},
});
export default @injectIntl
export default
@injectIntl
@connect(makeMapStateToProps, mapDispatchToProps)
class AccountCard extends ImmutablePureComponent {
@ -83,7 +102,7 @@ class AccountCard extends ImmutablePureComponent {
onMute: PropTypes.func.isRequired,
};
_updateEmojis () {
_updateEmojis() {
const node = this.node;
if (!node || autoPlayGif) {
@ -104,68 +123,113 @@ class AccountCard extends ImmutablePureComponent {
}
}
componentDidMount () {
componentDidMount() {
this._updateEmojis();
}
componentDidUpdate () {
componentDidUpdate() {
this._updateEmojis();
}
handleEmojiMouseEnter = ({ target }) => {
target.src = target.getAttribute('data-original');
}
};
handleEmojiMouseLeave = ({ target }) => {
target.src = target.getAttribute('data-static');
}
};
handleFollow = () => {
this.props.onFollow(this.props.account);
}
};
handleBlock = () => {
this.props.onBlock(this.props.account);
}
};
handleMute = () => {
this.props.onMute(this.props.account);
}
};
setRef = (c) => {
this.node = c;
}
};
render () {
render() {
const { account, intl } = this.props;
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 requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
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) {
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) {
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) {
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 (
<div className='directory__card'>
<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 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} />
<DisplayName account={account} />
</Permalink>
@ -176,13 +240,44 @@ class AccountCard extends ImmutablePureComponent {
</div>
<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 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'>{shortNumberFormat(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 className='accounts-table__count'>
<ShortNumber value={account.get('statuses_count')} />
<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>
);

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

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { changeUploadCompose } from '../../../actions/compose';
import { changeUploadCompose, uploadThumbnail } from '../../../actions/compose';
import { getPointerPosition } from '../../video';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'mastodon/components/icon_button';
@ -23,11 +23,13 @@ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
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 }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
account: state.getIn(['accounts', me]),
isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
});
const mapDispatchToProps = (dispatch, { id }) => ({
@ -36,6 +38,10 @@ const mapDispatchToProps = (dispatch, { id }) => ({
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, '******')
@ -81,6 +87,9 @@ class FocalPointModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.map.isRequired,
isUploadingThumbnail: PropTypes.bool,
onSave: PropTypes.func.isRequired,
onSelectThumbnail: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -235,13 +244,29 @@ class FocalPointModal extends ImmutablePureComponent {
}).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 () {
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 width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || null;
const focals = ['image', 'gifv'].includes(media.get('type'));
const thumbnailable = ['audio', 'video'].includes(media.get('type'));
const previewRatio = 16/9;
const previewWidth = 200;
@ -268,6 +293,30 @@ class FocalPointModal extends ImmutablePureComponent {
<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>}
{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'>
{descriptionLabel}
</label>
@ -293,7 +342,7 @@ class FocalPointModal extends ImmutablePureComponent {
<CharacterCounter max={1500} text={detecting ? '' : description} />
</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 className='focal-point-modal__content'>

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

@ -1,6 +1,5 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.account_note_header": "Note",
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.badges.bot": "Bot",
"account.badges.group": "Group",
@ -42,10 +41,7 @@
"account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"account_note.placeholder": "Click to add note",
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
"alert.rate_limited.title": "Rate limited",
"alert.unexpected.message": "An unexpected error occurred.",
@ -178,6 +174,7 @@
"follow_request.authorize": "Authorize",
"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.",
"generic.saved": "Saved",
"getting_started.developers": "Developers",
"getting_started.directory": "Profile directory",
"getting_started.documentation": "Documentation",
@ -377,7 +374,7 @@
"status.bookmark": "Bookmark",
"status.cancel_reblog_private": "Unboost",
"status.cannot_reblog": "This post cannot be boosted",
"status.copy": "Copy link to status",
"status.copy": "Copy link to toot",
"status.delete": "Delete",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}",

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

@ -14,6 +14,10 @@ import {
COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_UNDO,
COMPOSE_UPLOAD_PROGRESS,
THUMBNAIL_UPLOAD_REQUEST,
THUMBNAIL_UPLOAD_SUCCESS,
THUMBNAIL_UPLOAD_FAIL,
THUMBNAIL_UPLOAD_PROGRESS,
COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT,
@ -60,6 +64,8 @@ const initialState = ImmutableMap({
is_changing_upload: false,
is_uploading: false,
progress: 0,
isUploadingThumbnail: false,
thumbnailProgress: 0,
media_attachments: ImmutableList(),
pending_media_attachments: 0,
poll: null,
@ -332,6 +338,22 @@ export default function compose(state = initialState, action) {
return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS:
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:
return state.withMutations(map => {
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));

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

@ -1,16 +1,71 @@
import React, { Fragment } from 'react';
import { FormattedNumber } from 'react-intl';
export const shortNumberFormat = number => {
if (number < 1000) {
return <FormattedNumber value={number} />;
} else if (number < 10000) {
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>;
} else if (number < 1000000) {
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={0} />K</Fragment>;
} else if (number < 10000000) {
return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={1} />M</Fragment>;
} else {
return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={0} />M</Fragment>;
// @ts-check
export const DECIMAL_UNITS = Object.freeze({
ONE: 1,
TEN: 10,
HUNDRED: Math.pow(10, 2),
THOUSAND: Math.pow(10, 3),
MILLION: Math.pow(10, 6),
BILLION: Math.pow(10, 9),
TRILLION: Math.pow(10, 12),
});
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;
}

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

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

@ -45,7 +45,7 @@ class FetchLinkCardService < BaseService
def 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'
@html = res.body_with_limit
@html_charset = res.charset

@ -27,6 +27,7 @@
- elsif @theme[:supported_locales].include? 'en'
= javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous'
= 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'

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

@ -49,7 +49,25 @@ end
# 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_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|
p.script_src :self, :unsafe_inline, assets_host
p.style_src :self, :unsafe_inline, assets_host
end
PgHero::HomeController.after_action do
request.content_security_policy_nonce_generator = nil
end

@ -38,15 +38,6 @@ class Rack::Attack
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|
req.remote_ip == '127.0.0.1' || req.remote_ip == '::1'
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)
end
throttle('protected_paths', limit: 25, period: 5.minutes) do |req|
req.remote_ip if req.post? && req.path =~ PROTECTED_PATHS_REGEX
throttle('throttle_sign_up_attempts/ip', limit: 25, period: 5.minutes) do |req|
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
self.throttled_response = lambda do |env|

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

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

@ -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"
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|
t.bigint "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
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.string "file_file_name"
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"
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|
t.string "email", default: "", 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_moderation_notes", "accounts"
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", 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 "tombstones", "accounts", 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", "invites", on_delete: :nullify
add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify

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

@ -28,9 +28,8 @@ describe MediaController do
end
it 'raises when not permitted to view' do
status = Fabricate(:status)
status = Fabricate(:status, visibility: :direct)
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 }
expect(response).to have_http_status(404)

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

@ -20,6 +20,7 @@ RSpec.describe SuspendAccountService, type: :service do
let!(:passive_relationship) { Fabricate(:follow, target_account: account) }
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!(:endorsment) { Fabricate(:account_pin, account: passive_relationship.account, target_account: account) }
it 'deletes associated records' do
is_expected.to change {
@ -30,8 +31,9 @@ RSpec.describe SuspendAccountService, type: :service do
account.favourites,
account.active_relationships,
account.passive_relationships,
AccountPin.where(target_account: account),
].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
it 'sends a delete actor activity to all known inboxes' do

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save