diff --git a/Gemfile b/Gemfile index aad5095629..3152c00a09 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index d91d701915..f0f4023043 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index c224e1a03b..42534f8ce6 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -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 diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index f6a85d87e1..96d973394f 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -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 diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index efdb1d2268..c9b8408817 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -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/(?.+)\z}) + redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path) end diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index a8261ec2ba..0b1d09de93 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -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 diff --git a/app/javascript/mastodon/actions/account_notes.js b/app/javascript/mastodon/actions/account_notes.js index 059ed9e803..d174410002 100644 --- a/app/javascript/mastodon/actions/account_notes.js +++ b/app/javascript/mastodon/actions/account_notes.js @@ -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, - }; -}; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 6b73fc90e0..20341f9ec0 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -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, diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.js b/app/javascript/mastodon/components/autosuggest_hashtag.js index e2f4e320dd..9e9d888f83 100644 --- a/app/javascript/mastodon/components/autosuggest_hashtag.js +++ b/app/javascript/mastodon/components/autosuggest_hashtag.js @@ -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 && ( + total + day.uses * 1, 0)} + /> + ); return (
-
#{tag.name}
- {tag.history !== undefined &&
} +
+ #{tag.name} +
+ {tag.history !== undefined && ( +
+ +
+ )}
); } diff --git a/app/javascript/mastodon/components/common_counter.js b/app/javascript/mastodon/components/common_counter.js new file mode 100644 index 0000000000..4fdf3babfd --- /dev/null +++ b/app/javascript/mastodon/components/common_counter.js @@ -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) => {displayNumber} + : (displayNumber) => displayNumber; + + switch (counterType) { + case 'statuses': { + return (displayNumber, pluralReady) => ( + + ); + } + case 'following': { + return (displayNumber, pluralReady) => ( + + ); + } + case 'followers': { + return (displayNumber, pluralReady) => ( + + ); + } + default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`); + } +} diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js index 62d6132626..d766ca90d7 100644 --- a/app/javascript/mastodon/components/hashtag.js +++ b/app/javascript/mastodon/components/hashtag.js @@ -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) => ( + {displayNumber}, + }} + /> +); const Hashtag = ({ hashtag }) => (
- + #{hashtag.get('name')} - {shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)} }} /> +
- {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)} +
- day.get('uses')).toArray()}> + day.get('uses')) + .toArray()} + >
diff --git a/app/javascript/mastodon/components/short_number.js b/app/javascript/mastodon/components/short_number.js new file mode 100644 index 0000000000..535c17727d --- /dev/null +++ b/app/javascript/mastodon/components/short_number.js @@ -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 = ; + + // 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 = ( + + ); + + let values = { count, rawNumber }; + + switch (unit) { + case DECIMAL_UNITS.THOUSAND: { + return ( + + ); + } + case DECIMAL_UNITS.MILLION: { + return ( + + ); + } + case DECIMAL_UNITS.BILLION: { + return ( + + ); + } + // Not sure if we should go farther - @Sasha-Sorokin + default: return count; + } +} + +ShortNumberCounter.propTypes = { + value: PropTypes.arrayOf(PropTypes.number), +}; + +export default React.memo(ShortNumber); diff --git a/app/javascript/mastodon/features/account/components/account_note.js b/app/javascript/mastodon/features/account/components/account_note.js index 832a96a6ae..1787ce1abc 100644 --- a/app/javascript/mastodon/features/account/components/account_note.js +++ b/app/javascript/mastodon/features/account/components/account_note.js @@ -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' }, }); -export default @injectIntl -class Header extends ImmutablePureComponent { +class InlineAlert extends React.PureComponent { 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, - intl: PropTypes.object.isRequired, + show: PropTypes.bool, }; - handleChangeAccountNote = (e) => { - this.props.onChangeAccountNote(e.target.value); + state = { + mountMessage: false, }; - componentWillUnmount () { - if (this.props.isEditing) { - this.props.onCancelAccountNote(); - } - } + static TRANSITION_DELAY = 200; - handleKeyDown = e => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.props.onSaveAccountNote(); - } else if (e.keyCode === 27) { - this.props.onCancelAccountNote(); + 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 { account, accountNote, isEditing, isSubmitting, intl } = this.props; + const { show } = this.props; + const { mountMessage } = this.state; - if (!account || (!accountNote && !isEditing)) { + return ( + + {mountMessage && } + + ); + } + +} + +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; } - let action_buttons = null; - if (isEditing) { - action_buttons = ( -
- -
- -
- ); - } - - let note_container = null; - if (isEditing) { - note_container = ( -