diff --git a/app/soapbox/__fixtures__/truthsocial-status-with-external-video.json b/app/soapbox/__fixtures__/truthsocial-status-with-external-video.json new file mode 100644 index 000000000..eb6150192 --- /dev/null +++ b/app/soapbox/__fixtures__/truthsocial-status-with-external-video.json @@ -0,0 +1,95 @@ +{ + "id": "108046244464677537", + "created_at": "2022-03-30T15:40:53.287Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://truthsocial.com/users/alex/statuses/108046244464677537", + "url": "https://truthsocial.com/@alex/108046244464677537", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "content": "", + "reblog": null, + "application": { + "name": "Soapbox FE", + "website": "https://soapbox.pub/" + }, + "account": { + "id": "107759994408336377", + "username": "alex", + "acct": "alex", + "display_name": "Alex Gleason", + "locked": false, + "bot": false, + "discoverable": null, + "group": false, + "created_at": "2022-02-08T00:00:00.000Z", + "note": "

Launching Truth Social

", + "url": "https://truthsocial.com/@alex", + "avatar": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png", + "avatar_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/avatars/107/759/994/408/336/377/original/119cb0dd1fa615b7.png", + "header": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png", + "header_static": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/accounts/headers/107/759/994/408/336/377/original/31f62b0453ccf554.png", + "followers_count": 4713, + "following_count": 43, + "statuses_count": 7, + "last_status_at": "2022-03-30", + "verified": true, + "location": "Texas", + "website": "https://soapbox.pub/", + "emojis": [], + "fields": [] + }, + "media_attachments": [ + { + "id": "108046243948255335", + "type": "video", + "url": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/media_attachments/files/108/046/243/948/255/335/original/3b17ce701c0d6f08.mp4", + "preview_url": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/cache/preview_cards/images/000/543/912/original/e1fcf6ace01d9ded.jpg", + "external_video_id": "vwfnq9", + "remote_url": null, + "preview_remote_url": null, + "text_url": "https://truthsocial.com/media/SpbYvqKIT2VubC9FFn0", + "meta": { + "original": { + "width": 988, + "height": 556, + "frame_rate": "60/1", + "duration": 1.949025, + "bitrate": 402396 + } + }, + "description": null, + "blurhash": null + } + ], + "mentions": [], + "tags": [], + "emojis": [], + "card": { + "url": "https://rumble.com/vz1trd-video-upload-for-108046244464677537.html?mref=ummtf&mc=3nvg0", + "title": "Video upload for 108046244464677537", + "description": "", + "type": "video", + "author_name": "hostid1", + "author_url": "https://rumble.com/user/hostid1", + "provider_name": "Rumble.com", + "provider_url": "https://rumble.com/", + "html": "", + "width": 988, + "height": 556, + "image": "https://static-assets.truthsocial.com/tmtg:prime-truth-social-assets/cache/preview_cards/images/000/543/912/original/e1fcf6ace01d9ded.jpg", + "embed_url": "", + "blurhash": "UQH1;m~8sks,%M~9?DRk-mRnR+xs9cWVj[bH" + }, + "poll": null +} 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/icon_with_counter.js b/app/soapbox/components/icon_with_counter.tsx similarity index 68% rename from app/soapbox/components/icon_with_counter.js rename to app/soapbox/components/icon_with_counter.tsx index c7de5f896..0e09503cf 100644 --- a/app/soapbox/components/icon_with_counter.js +++ b/app/soapbox/components/icon_with_counter.tsx @@ -1,10 +1,15 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'soapbox/components/icon'; import { shortNumberFormat } from 'soapbox/utils/numbers'; -const IconWithCounter = ({ icon, count, ...rest }) => { +interface IIconWithCounter extends React.HTMLAttributes { + count: number, + icon?: string; + src?: string; +} + +const IconWithCounter: React.FC = ({ icon, count, ...rest }) => { return (
@@ -16,9 +21,4 @@ const IconWithCounter = ({ icon, count, ...rest }) => { ); }; -IconWithCounter.propTypes = { - icon: PropTypes.string, - count: PropTypes.number.isRequired, -}; - export default IconWithCounter; 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 {
= ({ poll, option, index, active onChange={handleOptionChange} /> - + {!showResults && ( -