diff --git a/app/soapbox/actions/__tests__/compose.test.js b/app/soapbox/actions/__tests__/compose.test.js new file mode 100644 index 000000000..73b64f801 --- /dev/null +++ b/app/soapbox/actions/__tests__/compose.test.js @@ -0,0 +1,111 @@ +import { InstanceRecord } from 'soapbox/normalizers'; +import rootReducer from 'soapbox/reducers'; +import { mockStore } from 'soapbox/test_helpers'; + +import { uploadCompose } from '../compose'; + +describe('uploadCompose()', () => { + describe('with images', () => { + let files, store; + + beforeEach(() => { + const instance = InstanceRecord({ + configuration: { + statuses: { + max_media_attachments: 4, + }, + media_attachments: { + image_size_limit: 10, + }, + }, + }); + + const state = rootReducer(undefined, {}) + .set('me', '1234') + .set('instance', instance); + + store = mockStore(state); + files = [{ + uri: 'image.png', + name: 'Image', + size: 15, + type: 'image/png', + }]; + }); + + it('creates an alert if exceeds max size', async() => { + const mockIntl = { + formatMessage: jest.fn().mockReturnValue('Image exceeds the current file size limit (10 Bytes)'), + }; + + const expectedActions = [ + { type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, + { + type: 'ALERT_SHOW', + message: 'Image exceeds the current file size limit (10 Bytes)', + actionLabel: undefined, + actionLink: undefined, + severity: 'error', + }, + { type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true }, + ]; + + await store.dispatch(uploadCompose(files, mockIntl)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with videos', () => { + let files, store; + + beforeEach(() => { + const instance = InstanceRecord({ + configuration: { + statuses: { + max_media_attachments: 4, + }, + media_attachments: { + video_size_limit: 10, + }, + }, + }); + + const state = rootReducer(undefined, {}) + .set('me', '1234') + .set('instance', instance); + + store = mockStore(state); + files = [{ + uri: 'video.mp4', + name: 'Video', + size: 15, + type: 'video/mp4', + }]; + }); + + it('creates an alert if exceeds max size', async() => { + const mockIntl = { + formatMessage: jest.fn().mockReturnValue('Video exceeds the current file size limit (10 Bytes)'), + }; + + const expectedActions = [ + { type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, + { + type: 'ALERT_SHOW', + message: 'Video exceeds the current file size limit (10 Bytes)', + actionLabel: undefined, + actionLink: undefined, + severity: 'error', + }, + { type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true }, + ]; + + await store.dispatch(uploadCompose(files, mockIntl)); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); diff --git a/app/soapbox/actions/alerts.js b/app/soapbox/actions/alerts.js index d1ba11fdb..c71ce3e87 100644 --- a/app/soapbox/actions/alerts.js +++ b/app/soapbox/actions/alerts.js @@ -1,5 +1,7 @@ import { defineMessages } from 'react-intl'; +import { httpErrorMessages } from 'soapbox/utils/errors'; + const messages = defineMessages({ unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, @@ -34,7 +36,7 @@ export function showAlert(title = messages.unexpectedTitle, message = messages.u } export function showAlertForError(error) { - return (dispatch, getState) => { + return (dispatch, _getState) => { if (error.response) { const { data, status, statusText } = error.response; @@ -48,13 +50,16 @@ export function showAlertForError(error) { } let message = statusText; - const title = `${status}`; if (data.error) { message = data.error; } - return dispatch(showAlert(title, message, 'error')); + if (!message) { + message = httpErrorMessages.find((httpError) => httpError.code === status)?.description; + } + + return dispatch(showAlert('', message, 'error')); } else { console.error(error); return dispatch(showAlert(undefined, undefined, 'error')); diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js index 25e5fa4f1..9f1555ae9 100644 --- a/app/soapbox/actions/compose.js +++ b/app/soapbox/actions/compose.js @@ -6,6 +6,7 @@ import { defineMessages } from 'react-intl'; import snackbar from 'soapbox/actions/snackbar'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; +import { formatBytes } from 'soapbox/utils/media'; import api from '../api'; import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; @@ -78,10 +79,12 @@ export const COMPOSE_ADD_TO_MENTIONS = 'COMPOSE_ADD_TO_MENTIONS'; export const COMPOSE_REMOVE_FROM_MENTIONS = 'COMPOSE_REMOVE_FROM_MENTIONS'; const messages = defineMessages({ - uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, - uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, + exceededImageSizeLimit: { id: 'upload_error.image_size_limit', defaultMessage: 'Image exceeds the current file size limit ({limit})' }, + exceededVideoSizeLimit: { id: 'upload_error.video_size_limit', defaultMessage: 'Video exceeds the current file size limit ({limit})' }, scheduleError: { id: 'compose.invalid_schedule', defaultMessage: 'You must schedule a post at least 5 minutes out.' }, success: { id: 'compose.submit_success', defaultMessage: 'Your post was sent' }, + uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, + uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, view: { id: 'snackbar.view', defaultMessage: 'View' }, }); @@ -295,10 +298,12 @@ export function submitComposeFail(error) { }; } -export function uploadCompose(files) { +export function uploadCompose(files, intl) { return function(dispatch, getState) { if (!isLoggedIn(getState)) return; const attachmentLimit = getState().getIn(['instance', 'configuration', 'statuses', 'max_media_attachments']); + const maxImageSize = getState().getIn(['instance', 'configuration', 'media_attachments', 'image_size_limit']); + const maxVideoSize = getState().getIn(['instance', 'configuration', 'media_attachments', 'video_size_limit']); const media = getState().getIn(['compose', 'media_attachments']); const progress = new Array(files.length).fill(0); @@ -314,6 +319,22 @@ export function uploadCompose(files) { Array.from(files).forEach((f, i) => { if (media.size + i > attachmentLimit - 1) return; + const isImage = f.type.match(/image.*/); + const isVideo = f.type.match(/video.*/); + if (isImage && maxImageSize && (f.size > maxImageSize)) { + const limit = formatBytes(maxImageSize); + const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); + dispatch(snackbar.error(message)); + dispatch(uploadComposeFail(true)); + return; + } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) { + const limit = formatBytes(maxVideoSize); + const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); + dispatch(snackbar.error(message)); + dispatch(uploadComposeFail(true)); + return; + } + // FIXME: Don't define function in loop /* eslint-disable no-loop-func */ resizeImage(f).then(file => { diff --git a/app/soapbox/components/modal_root.js b/app/soapbox/components/modal_root.js index feefbf6d6..628d02fa4 100644 --- a/app/soapbox/components/modal_root.js +++ b/app/soapbox/components/modal_root.js @@ -203,7 +203,7 @@ class ModalRoot extends React.PureComponent {
{ }; }; -const mapDispatchToProps = (dispatch) => ({ +const mapDispatchToProps = (dispatch, { intl }) => ({ onChange(text) { dispatch(changeCompose(text)); @@ -66,7 +66,7 @@ const mapDispatchToProps = (dispatch) => ({ }, onPaste(files) { - dispatch(uploadCompose(files)); + dispatch(uploadCompose(files, intl)); }, onPickEmoji(position, data, needsSpace) { diff --git a/app/soapbox/features/compose/containers/upload_button_container.js b/app/soapbox/features/compose/containers/upload_button_container.js index 160cedf54..2f6542942 100644 --- a/app/soapbox/features/compose/containers/upload_button_container.js +++ b/app/soapbox/features/compose/containers/upload_button_container.js @@ -1,3 +1,4 @@ +import { injectIntl } from 'react-intl'; import { connect } from 'react-redux'; import { uploadCompose } from '../../../actions/compose'; @@ -8,12 +9,12 @@ const mapStateToProps = state => ({ resetFileKey: state.getIn(['compose', 'resetFileKey']), }); -const mapDispatchToProps = dispatch => ({ +const mapDispatchToProps = (dispatch, { intl }) => ({ onSelectFile(files) { - dispatch(uploadCompose(files)); + dispatch(uploadCompose(files, intl)); }, }); -export default connect(mapStateToProps, mapDispatchToProps)(UploadButton); +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(UploadButton)); diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 598a9fe04..8f3caa2f5 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -426,7 +426,7 @@ class UI extends React.PureComponent { this.dragTargets = []; if (e.dataTransfer && e.dataTransfer.files.length >= 1) { - this.props.dispatch(uploadCompose(e.dataTransfer.files)); + this.props.dispatch(uploadCompose(e.dataTransfer.files, this.props.intl)); } } diff --git a/app/soapbox/utils/errors.js b/app/soapbox/utils/errors.js deleted file mode 100644 index 1bf59e4f0..000000000 --- a/app/soapbox/utils/errors.js +++ /dev/null @@ -1,21 +0,0 @@ -import camelCase from 'lodash/camelCase'; -import startCase from 'lodash/startCase'; - -const toSentence = (arr) => arr - .reduce( - (prev, curr, i) => prev + curr + (i === arr.length - 2 ? ' and ' : ', '), - '', - ) - .slice(0, -2); - -const buildErrorMessage = (errors) => { - const individualErrors = Object.keys(errors).map( - (attribute) => `${startCase(camelCase(attribute))} ${toSentence( - errors[attribute], - )}`, - ); - - return toSentence(individualErrors); -}; - -export { buildErrorMessage }; diff --git a/app/soapbox/utils/errors.ts b/app/soapbox/utils/errors.ts new file mode 100644 index 000000000..e5e04311c --- /dev/null +++ b/app/soapbox/utils/errors.ts @@ -0,0 +1,203 @@ +import camelCase from 'lodash/camelCase'; +import startCase from 'lodash/startCase'; + +const toSentence = (arr: string[]) => arr + .reduce( + (prev, curr, i) => prev + curr + (i === arr.length - 2 ? ' and ' : ', '), + '', + ) + .slice(0, -2); + +type Errors = { + [key: string]: string[] +} + +const buildErrorMessage = (errors: Errors) => { + const individualErrors = Object.keys(errors).map( + (attribute) => `${startCase(camelCase(attribute))} ${toSentence( + errors[attribute], + )}`, + ); + + return toSentence(individualErrors); +}; + +const httpErrorMessages: { code: number, name: string, description: string }[] = [ + { + code: 100, + name: 'Continue', + description: 'The server has received the request headers, and the client should proceed to send the request body', + }, + { + code: 101, + name: 'Switching Protocols', + description: 'The requester has asked the server to switch protocols', + }, + { + code: 103, + name: 'Checkpoint', + description: 'Used in the resumable requests proposal to resume aborted PUT or POST requests', + }, + { + code: 200, + name: 'OK', + description: 'The request is OK (this is the standard response for successful HTTP requests)', + }, + { + code: 201, + name: 'Created', + description: 'The request has been fulfilled', + }, + { + code: 202, + name: 'Accepted', + description: 'The request has been accepted for processing', + }, + { + code: 203, + name: 'Non-Authoritative Information', + description: 'The request has been successfully processed', + }, + { + code: 204, + name: 'No Content', + description: 'The request has been successfully processed', + }, + { + code: 205, + name: 'Reset Content', + description: 'The request has been successfully processed', + }, + { + code: 206, + name: 'Partial Content', + description: 'The server is delivering only part of the resource due to a range header sent by the client', + }, + { + code: 400, + name: 'Bad Request', + description: 'The request cannot be fulfilled due to bad syntax', + }, + { + code: 401, + name: 'Unauthorized', + description: 'The request was a legal request', + }, + { + code: 402, + name: 'Payment Required', + description: 'Reserved for future use', + }, + { + code: 403, + name: 'Forbidden', + description: 'The request was a legal request', + }, + { + code: 404, + name: 'Not Found', + description: 'The requested page could not be found but may be available again in the future', + }, + { + code: 405, + name: 'Method Not Allowed', + description: 'A request was made of a page using a request method not supported by that page', + }, + { + code: 406, + name: 'Not Acceptable', + description: 'The server can only generate a response that is not accepted by the client', + }, + { + code: 407, + name: 'Proxy Authentication Required', + description: 'The client must first authenticate itself with the proxy', + }, + { + code: 408, + name: 'Request', + description: ' Timeout\tThe server timed out waiting for the request', + }, + { + code: 409, + name: 'Conflict', + description: 'The request could not be completed because of a conflict in the request', + }, + { + code: 410, + name: 'Gone', + description: 'The requested page is no longer available', + }, + { + code: 411, + name: 'Length Required', + description: 'The "Content-Length" is not defined. The server will not accept the request without it', + }, + { + code: 412, + name: 'Precondition', + description: ' Failed. The precondition given in the request evaluated to false by the server', + }, + { + code: 413, + name: 'Request Entity Too Large', + description: 'The server will not accept the request', + }, + { + code: 414, + name: 'Request-URI Too Long', + description: 'The server will not accept the request', + }, + { + code: 415, + name: 'Unsupported Media Type', + description: 'The server will not accept the request', + }, + { + code: 416, + name: 'Requested Range Not Satisfiable', + description: 'The client has asked for a portion of the file', + }, + { + code: 417, + name: 'Expectation Failed', + description: 'The server cannot meet the requirements of the Expect request-header field', + }, + { + code: 500, + name: 'Internal Server Error', + description: 'A generic error message', + }, + { + code: 501, + name: 'Not Implemented', + description: 'The server either does not recognize the request method', + }, + { + code: 502, + name: 'Bad Gateway', + description: 'The server was acting as a gateway or proxy and received an invalid response from the upstream server', + }, + { + code: 503, + name: 'Service Unavailable', + description: 'The server is currently unavailable (overloaded or down)', + }, + { + code: 504, + name: 'Gateway Timeout', + description: 'The server was acting as a gateway or proxy and did not receive a timely response from the upstream server', + }, + { + code: 505, + name: 'HTTP Version Not Supported', + description: 'The server does not support the HTTP protocol version used in the request', + }, + { + code: 511, + name: 'Network Authentication Required', + description: 'The client needs to auth', + }, +]; + +export { buildErrorMessage, httpErrorMessages }; diff --git a/app/soapbox/utils/media.js b/app/soapbox/utils/media.js deleted file mode 100644 index c8266c646..000000000 --- a/app/soapbox/utils/media.js +++ /dev/null @@ -1,10 +0,0 @@ -export const truncateFilename = (url, maxLength) => { - const filename = url.split('/').pop(); - - if (filename.length <= maxLength) return filename; - - return [ - filename.substr(0, maxLength/2), - filename.substr(filename.length - maxLength/2), - ].join('…'); -}; diff --git a/app/soapbox/utils/media.ts b/app/soapbox/utils/media.ts new file mode 100644 index 000000000..74fc4d3f8 --- /dev/null +++ b/app/soapbox/utils/media.ts @@ -0,0 +1,28 @@ +const truncateFilename = (url: string, maxLength: number) => { + const filename = url.split('/').pop(); + + if (!filename) { + return filename; + } + + if (filename.length <= maxLength) return filename; + + return [ + filename.substr(0, maxLength/2), + filename.substr(filename.length - maxLength/2), + ].join('…'); +}; + +const formatBytes = (bytes: number, decimals: number = 2) => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +}; + +export { formatBytes, truncateFilename }; diff --git a/app/styles/components/snackbar.scss b/app/styles/components/snackbar.scss index 7a397ef1e..56c4cb2b7 100644 --- a/app/styles/components/snackbar.scss +++ b/app/styles/components/snackbar.scss @@ -1,5 +1,5 @@ .notification-list { - @apply w-full flex flex-col items-center space-y-2 sm:items-end; + @apply w-full flex flex-col items-center space-y-2 sm:items-end z-[1001] relative; } .notification-bar {