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 {