From a9b83710869876fa6377e129826b07a5235d81fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 10 Sep 2022 23:52:06 +0200 Subject: [PATCH] Allow multiple compose forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/compose.ts | 170 ++-- .../components/autosuggest_textarea.tsx | 4 +- app/soapbox/components/modal_root.js | 6 +- app/soapbox/components/status-action-bar.tsx | 26 +- app/soapbox/components/status.tsx | 4 +- app/soapbox/components/ui/stack/stack.tsx | 2 +- .../compose/components/compose-form.tsx | 363 +++++++ .../compose/components/compose_form.js | 402 -------- .../compose/components/markdown_button.tsx | 13 +- .../compose/components/poll_button.tsx | 21 +- .../compose/components/polls/poll-form.tsx | 47 +- .../compose/components/privacy_dropdown.tsx | 31 +- .../compose/components/reply_mentions.tsx | 10 +- .../compose/components/schedule_button.tsx | 21 +- .../compose/components/schedule_form.tsx | 12 +- .../compose/components/sensitive-button.tsx | 12 +- .../compose/components/spoiler_button.tsx | 14 +- .../compose/components/upload-progress.tsx | 10 +- .../compose/components/upload_button.tsx | 10 +- .../compose/components/upload_form.tsx | 12 +- .../containers/compose_form_container.js | 87 -- .../containers/markdown_button_container.js | 23 - .../containers/poll_button_container.js | 25 - .../containers/privacy_dropdown_container.js | 28 - .../containers/quoted_status_container.tsx | 8 +- .../containers/reply_indicator_container.js | 31 - .../containers/reply_indicator_container.ts | 35 + .../containers/schedule_button_container.js | 25 - .../containers/schedule_form_container.js | 16 - .../containers/schedule_form_container.tsx | 14 + .../containers/spoiler_button_container.js | 18 - .../containers/upload_button_container.js | 20 - .../containers/upload_button_container.ts | 23 + ...ing_container.js => warning_container.tsx} | 31 +- app/soapbox/features/groups/timeline/index.js | 4 +- .../features/reply_mentions/account.tsx | 5 +- app/soapbox/features/status/index.tsx | 11 +- .../features/ui/components/compose_modal.tsx | 14 +- .../ui/components/reply_mentions_modal.tsx | 7 +- app/soapbox/features/ui/index.tsx | 2 +- app/soapbox/pages/home_page.tsx | 6 +- .../reducers/__tests__/compose.test.ts | 906 +++++++++--------- app/soapbox/reducers/compose.ts | 230 ++--- 43 files changed, 1266 insertions(+), 1493 deletions(-) create mode 100644 app/soapbox/features/compose/components/compose-form.tsx delete mode 100644 app/soapbox/features/compose/components/compose_form.js delete mode 100644 app/soapbox/features/compose/containers/compose_form_container.js delete mode 100644 app/soapbox/features/compose/containers/markdown_button_container.js delete mode 100644 app/soapbox/features/compose/containers/poll_button_container.js delete mode 100644 app/soapbox/features/compose/containers/privacy_dropdown_container.js delete mode 100644 app/soapbox/features/compose/containers/reply_indicator_container.js create mode 100644 app/soapbox/features/compose/containers/reply_indicator_container.ts delete mode 100644 app/soapbox/features/compose/containers/schedule_button_container.js delete mode 100644 app/soapbox/features/compose/containers/schedule_form_container.js create mode 100644 app/soapbox/features/compose/containers/schedule_form_container.tsx delete mode 100644 app/soapbox/features/compose/containers/spoiler_button_container.js delete mode 100644 app/soapbox/features/compose/containers/upload_button_container.js create mode 100644 app/soapbox/features/compose/containers/upload_button_container.ts rename app/soapbox/features/compose/containers/{warning_container.js => warning_container.tsx} (62%) diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 804141cd8..7cd5fcfb6 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -54,9 +54,6 @@ const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE'; const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; -const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; -const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; - const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE'; @@ -101,14 +98,6 @@ const messages = defineMessages({ replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, }); -const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1); - -const ensureComposeIsVisible = (getState: () => RootState, routerHistory: History) => { - if (!getState().compose.mounted && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) { - routerHistory.push('/posts/new'); - } -}; - const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean) => (dispatch: AppDispatch, getState: () => RootState) => { const { instance } = getState(); @@ -126,8 +115,9 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin }); }; -const changeCompose = (text: string) => ({ +const changeCompose = (composeId: string, text: string) => ({ type: COMPOSE_CHANGE, + id: composeId, text: text, }); @@ -147,20 +137,6 @@ const replyCompose = (status: Status) => dispatch(openModal('COMPOSE')); }; -const replyComposeWithConfirmation = (status: Status, intl: IntlShape) => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState(); - if (state.compose.text.trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(replyCompose(status)), - })); - } else { - dispatch(replyCompose(status)); - } - }; - const cancelReplyCompose = () => ({ type: COMPOSE_REPLY_CANCEL, }); @@ -221,16 +197,16 @@ const directComposeById = (accountId: string) => dispatch(openModal('COMPOSE')); }; -const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, data: APIEntity, status: string, edit?: boolean) => { +const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, composeId: string, data: APIEntity, status: string, edit?: boolean) => { if (!dispatch || !getState) return; - dispatch(insertIntoTagHistory(data.tags || [], status)); - dispatch(submitComposeSuccess({ ...data })); + dispatch(insertIntoTagHistory(composeId, data.tags || [], status)); + dispatch(submitComposeSuccess(composeId, { ...data })); dispatch(snackbar.success(edit ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`)); }; -const needsDescriptions = (state: RootState) => { - const media = state.compose.media_attachments; +const needsDescriptions = (state: RootState, composeId: string) => { + const media = state.compose.get(composeId)!.media_attachments; const missingDescriptionModal = getSettings(state).get('missingDescriptionModal'); const hasMissing = media.filter(item => !item.description).size > 0; @@ -238,8 +214,8 @@ const needsDescriptions = (state: RootState) => { return missingDescriptionModal && hasMissing; }; -const validateSchedule = (state: RootState) => { - const schedule = state.compose.schedule; +const validateSchedule = (state: RootState, composeId: string) => { + const schedule = state.compose.get(composeId)!.schedule; if (!schedule) return true; const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); @@ -247,17 +223,19 @@ const validateSchedule = (state: RootState) => { return schedule.getTime() > fiveMinutesFromNow.getTime(); }; -const submitCompose = (routerHistory?: History, force = false) => +const submitCompose = (composeId: string, routerHistory?: History, force = false) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; const state = getState(); - const status = state.compose.text; - const media = state.compose.media_attachments; - const statusId = state.compose.id; - let to = state.compose.to; + const compose = state.compose.get(composeId)!; - if (!validateSchedule(state)) { + const status = compose.text; + const media = compose.media_attachments; + const statusId = compose.id; + let to = compose.to; + + if (!validateSchedule(state, composeId)) { dispatch(snackbar.error(messages.scheduleError)); return; } @@ -266,11 +244,11 @@ const submitCompose = (routerHistory?: History, force = false) => return; } - if (!force && needsDescriptions(state)) { + if (!force && needsDescriptions(state, composeId)) { dispatch(openModal('MISSING_DESCRIPTION', { onContinue: () => { dispatch(closeModal('MISSING_DESCRIPTION')); - dispatch(submitCompose(routerHistory, true)); + dispatch(submitCompose(composeId, routerHistory, true)); }, })); return; @@ -282,22 +260,22 @@ const submitCompose = (routerHistory?: History, force = false) => to = to.union(mentions.map(mention => mention.trim().slice(1))); } - dispatch(submitComposeRequest()); + dispatch(submitComposeRequest(composeId)); dispatch(closeModal()); - const idempotencyKey = state.compose.idempotencyKey; + const idempotencyKey = compose.idempotencyKey; const params = { status, - in_reply_to_id: state.compose.in_reply_to, - quote_id: state.compose.quote, + in_reply_to_id: compose.in_reply_to, + quote_id: compose.quote, media_ids: media.map(item => item.id), - sensitive: state.compose.sensitive, - spoiler_text: state.compose.spoiler_text, - visibility: state.compose.privacy, - content_type: state.compose.content_type, - poll: state.compose.poll, - scheduled_at: state.compose.schedule, + sensitive: compose.sensitive, + spoiler_text: compose.spoiler_text, + visibility: compose.privacy, + content_type: compose.content_type, + poll: compose.poll, + scheduled_at: compose.schedule, to, }; @@ -305,27 +283,30 @@ const submitCompose = (routerHistory?: History, force = false) => if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) { routerHistory.push('/messages'); } - handleComposeSubmit(dispatch, getState, data, status, !!statusId); + handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId); }).catch(function(error) { - dispatch(submitComposeFail(error)); + dispatch(submitComposeFail(composeId, error)); }); }; -const submitComposeRequest = () => ({ +const submitComposeRequest = (composeId: string) => ({ type: COMPOSE_SUBMIT_REQUEST, + id: composeId, }); -const submitComposeSuccess = (status: APIEntity) => ({ +const submitComposeSuccess = (composeId: string, status: APIEntity) => ({ type: COMPOSE_SUBMIT_SUCCESS, + id: composeId, status: status, }); -const submitComposeFail = (error: AxiosError) => ({ +const submitComposeFail = (composeId: string, error: AxiosError) => ({ type: COMPOSE_SUBMIT_FAIL, + id: composeId, error: error, }); -const uploadCompose = (files: FileList, intl: IntlShape) => +const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number; @@ -333,7 +314,7 @@ const uploadCompose = (files: FileList, intl: IntlShape) => const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined; const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined; - const media = getState().compose.media_attachments; + const media = getState().compose.get(composeId)!.media_attachments; const progress = new Array(files.length).fill(0); let total = Array.from(files).reduce((a, v) => a + v.size, 0); @@ -576,10 +557,10 @@ const updateTagHistory = (tags: string[]) => ({ tags, }); -const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) => +const insertIntoTagHistory = (composeId: string, recognizedTags: APIEntity[], text: string) => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); - const oldHistory = state.compose.tagHistory; + const oldHistory = state.compose.get(composeId)!.tagHistory; const me = state.me; const names = recognizedTags .filter(tag => text.match(new RegExp(`#${tag.name}`, 'i'))) @@ -594,97 +575,99 @@ const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) => dispatch(updateTagHistory(newHistory)); }; -const mountCompose = () => ({ - type: COMPOSE_MOUNT, -}); - -const unmountCompose = () => ({ - type: COMPOSE_UNMOUNT, -}); - -const changeComposeSensitivity = () => ({ +const changeComposeSensitivity = (composeId: string) => ({ type: COMPOSE_SENSITIVITY_CHANGE, + id: composeId, }); -const changeComposeSpoilerness = () => ({ +const changeComposeSpoilerness = (composeId: string) => ({ type: COMPOSE_SPOILERNESS_CHANGE, + id: composeId, }); -const changeComposeContentType = (value: string) => ({ +const changeComposeContentType = (composeId: string, value: string) => ({ type: COMPOSE_TYPE_CHANGE, + id: composeId, value, }); -const changeComposeSpoilerText = (text: string) => ({ +const changeComposeSpoilerText = (composeId: string, text: string) => ({ type: COMPOSE_SPOILER_TEXT_CHANGE, + id: composeId, text, }); -const changeComposeVisibility = (value: string) => ({ +const changeComposeVisibility = (composeId: string, value: string) => ({ type: COMPOSE_VISIBILITY_CHANGE, + id: composeId, value, }); -const insertEmojiCompose = (position: number, emoji: string, needsSpace: boolean) => ({ +const insertEmojiCompose = (composeId: string, position: number, emoji: Emoji, needsSpace: boolean) => ({ type: COMPOSE_EMOJI_INSERT, + id: composeId, position, emoji, needsSpace, }); -const changeComposing = (value: string) => ({ - type: COMPOSE_COMPOSING_CHANGE, - value, -}); - -const addPoll = () => ({ +const addPoll = (composeId: string) => ({ type: COMPOSE_POLL_ADD, + id: composeId, }); -const removePoll = () => ({ +const removePoll = (composeId: string) => ({ type: COMPOSE_POLL_REMOVE, + id: composeId, }); -const addSchedule = () => ({ +const addSchedule = (composeId: string) => ({ type: COMPOSE_SCHEDULE_ADD, + id: composeId, }); -const setSchedule = (date: Date) => ({ +const setSchedule = (composeId: string, date: Date) => ({ type: COMPOSE_SCHEDULE_SET, + id: composeId, date: date, }); -const removeSchedule = () => ({ +const removeSchedule = (composeId: string) => ({ type: COMPOSE_SCHEDULE_REMOVE, + id: composeId, }); -const addPollOption = (title: string) => ({ +const addPollOption = (composeId: string, title: string) => ({ type: COMPOSE_POLL_OPTION_ADD, + id: composeId, title, }); -const changePollOption = (index: number, title: string) => ({ +const changePollOption = (composeId: string, index: number, title: string) => ({ type: COMPOSE_POLL_OPTION_CHANGE, + id: composeId, index, title, }); -const removePollOption = (index: number) => ({ +const removePollOption = (composeId: string, index: number) => ({ type: COMPOSE_POLL_OPTION_REMOVE, + id: composeId, index, }); -const changePollSettings = (expiresIn?: string | number, isMultiple?: boolean) => ({ +const changePollSettings = (composeId: string, expiresIn?: string | number, isMultiple?: boolean) => ({ type: COMPOSE_POLL_SETTINGS_CHANGE, + id: composeId, expiresIn, isMultiple, }); -const openComposeWithText = (text = '') => +const openComposeWithText = (composeId: string, text = '') => (dispatch: AppDispatch) => { dispatch(resetCompose()); dispatch(openModal('COMPOSE')); - dispatch(changeCompose(text)); + dispatch(changeCompose(composeId, text)); }; const addToMentions = (accountId: string) => @@ -731,8 +714,6 @@ export { COMPOSE_SUGGESTION_SELECT, COMPOSE_SUGGESTION_TAGS_UPDATE, COMPOSE_TAG_HISTORY_UPDATE, - COMPOSE_MOUNT, - COMPOSE_UNMOUNT, COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SPOILERNESS_CHANGE, COMPOSE_TYPE_CHANGE, @@ -756,11 +737,9 @@ export { COMPOSE_ADD_TO_MENTIONS, COMPOSE_REMOVE_FROM_MENTIONS, COMPOSE_SET_STATUS, - ensureComposeIsVisible, setComposeToStatus, changeCompose, replyCompose, - replyComposeWithConfirmation, cancelReplyCompose, quoteCompose, cancelQuoteCompose, @@ -790,15 +769,12 @@ export { selectComposeSuggestion, updateSuggestionTags, updateTagHistory, - mountCompose, - unmountCompose, changeComposeSensitivity, changeComposeSpoilerness, changeComposeContentType, changeComposeSpoilerText, changeComposeVisibility, insertEmojiCompose, - changeComposing, addPoll, removePoll, addSchedule, diff --git a/app/soapbox/components/autosuggest_textarea.tsx b/app/soapbox/components/autosuggest_textarea.tsx index 47f0eea39..c1c849b07 100644 --- a/app/soapbox/components/autosuggest_textarea.tsx +++ b/app/soapbox/components/autosuggest_textarea.tsx @@ -46,8 +46,8 @@ interface IAutosuggesteTextarea { onSuggestionsClearRequested: () => void, onSuggestionsFetchRequested: (token: string | number) => void, onChange: React.ChangeEventHandler, - onKeyUp: React.KeyboardEventHandler, - onKeyDown: React.KeyboardEventHandler, + onKeyUp?: React.KeyboardEventHandler, + onKeyDown?: React.KeyboardEventHandler, onPaste: (files: FileList) => void, autoFocus: boolean, onFocus: () => void, diff --git a/app/soapbox/components/modal_root.js b/app/soapbox/components/modal_root.js index 23d392b51..0e08ee50b 100644 --- a/app/soapbox/components/modal_root.js +++ b/app/soapbox/components/modal_root.js @@ -15,7 +15,7 @@ const messages = defineMessages({ }); export const checkComposeContent = compose => { - return [ + return !!compose && [ compose.text.length > 0, compose.spoiler_text.length > 0, compose.media_attachments.size > 0, @@ -24,8 +24,8 @@ export const checkComposeContent = compose => { }; const mapStateToProps = state => ({ - hasComposeContent: checkComposeContent(state.compose), - isEditing: state.compose.id !== null, + hasComposeContent: checkComposeContent(state.compose.get('modal')), + isEditing: state.compose.get('modal')?.id !== null, }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 5bce513a5..f664a9266 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -123,18 +123,7 @@ const StatusActionBar: React.FC = ({ const handleReplyClick: React.MouseEventHandler = (e) => { if (me) { - dispatch((_, getState) => { - const state = getState(); - if (state.compose.text.trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(replyCompose(status)), - })); - } else { - dispatch(replyCompose(status)); - } - }); + dispatch(replyCompose(status)); } else { onOpenUnauthorizedModal('REPLY'); } @@ -186,18 +175,7 @@ const StatusActionBar: React.FC = ({ e.stopPropagation(); if (me) { - dispatch((_, getState) => { - const state = getState(); - if (state.compose.text.trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(quoteCompose(status)), - })); - } else { - dispatch(quoteCompose(status)); - } - }); + dispatch(quoteCompose(status)); } else { onOpenUnauthorizedModal('REBLOG'); } diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index dd2a15142..609124c20 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -4,7 +4,7 @@ import { HotKeys } from 'react-hotkeys'; import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; import { NavLink, useHistory } from 'react-router-dom'; -import { mentionCompose, replyComposeWithConfirmation } from 'soapbox/actions/compose'; +import { mentionCompose, replyCompose } from 'soapbox/actions/compose'; import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { toggleStatusHidden } from 'soapbox/actions/statuses'; @@ -125,7 +125,7 @@ const Status: React.FC = (props) => { const handleHotkeyReply = (e?: KeyboardEvent): void => { e?.preventDefault(); - dispatch(replyComposeWithConfirmation(actualStatus, intl)); + dispatch(replyCompose(actualStatus)); }; const handleHotkeyFavourite = (): void => { diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 18796baaa..64257ecf9 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -37,7 +37,7 @@ interface IStack extends React.HTMLAttributes { } /** Vertical stack of child elements. */ -const Stack: React.FC = React.forwardRef((props, ref: React.LegacyRef | undefined) => { +const Stack = React.forwardRef((props, ref: React.LegacyRef | undefined) => { const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props; return ( diff --git a/app/soapbox/features/compose/components/compose-form.tsx b/app/soapbox/features/compose/components/compose-form.tsx new file mode 100644 index 000000000..fc93b1ab0 --- /dev/null +++ b/app/soapbox/features/compose/components/compose-form.tsx @@ -0,0 +1,363 @@ +import classNames from 'clsx'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Link, useHistory } from 'react-router-dom'; +import { length } from 'stringz'; + +import { + changeCompose, + submitCompose, + clearComposeSuggestions, + fetchComposeSuggestions, + selectComposeSuggestion, + changeComposeSpoilerText, + insertEmojiCompose, + uploadCompose, +} from 'soapbox/actions/compose'; +import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest_input'; +import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea'; +import Icon from 'soapbox/components/icon'; +import { Button, Stack } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; +import { isMobile } from 'soapbox/is_mobile'; + +import MarkdownButton from '../components/markdown_button'; +import PollButton from '../components/poll_button'; +import PollForm from '../components/polls/poll-form'; +import PrivacyDropdown from '../components/privacy_dropdown'; +import ReplyMentions from '../components/reply_mentions'; +import ScheduleButton from '../components/schedule_button'; +import SpoilerButton from '../components/spoiler_button'; +import UploadForm from '../components/upload_form'; +import Warning from '../components/warning'; +import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; +import QuotedStatusContainer from '../containers/quoted_status_container'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; +import ScheduleFormContainer from '../containers/schedule_form_container'; +import UploadButtonContainer from '../containers/upload_button_container'; +import WarningContainer from '../containers/warning_container'; +import { countableText } from '../util/counter'; + +import TextCharacterCounter from './text_character_counter'; +import VisualCharacterCounter from './visual_character_counter'; + +import type { Emoji } from 'soapbox/components/autosuggest_emoji'; + +const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; + +const messages = defineMessages({ + placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' }, + pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic...' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, + publish: { id: 'compose_form.publish', defaultMessage: 'Post' }, + publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, + message: { id: 'compose_form.message', defaultMessage: 'Message' }, + schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' }, + saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' }, +}); + +interface IComposeForm { + id: string, + shouldCondense?: boolean, + autoFocus?: boolean, + clickableAreaRef?: React.RefObject, +} + +const ComposeForm: React.FC = ({ id, shouldCondense, autoFocus, clickableAreaRef }) => { + const history = useHistory(); + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const compose = useAppSelector((state) => state.compose.get(id)!); + const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden); + const isModalOpen = useAppSelector((state) => !!(state.modals.size && state.modals.last()!.modalType === 'COMPOSE')); + const maxTootChars = useAppSelector((state) => state.instance.getIn(['configuration', 'statuses', 'max_characters'])) as number; + const scheduledStatusCount = useAppSelector((state) => state.get('scheduled_statuses').size); + const features = useFeatures(); + + const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose; + + const hasPoll = !!compose.poll; + const isEditing = compose.id !== null; + const anyMedia = compose.media_attachments.size > 0; + + const [composeFocused, setComposeFocused] = useState(false); + + const formRef = useRef(null); + const spoilerTextRef = useRef(null); + const autosuggestTextareaRef = useRef(null); + + const handleChange: React.ChangeEventHandler = (e) => { + dispatch(changeCompose(id, e.target.value)); + }; + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + handleSubmit(); + e.preventDefault(); // Prevent bubbling to other ComposeForm instances + } + }; + + const getClickableArea = () => { + return clickableAreaRef ? clickableAreaRef.current : formRef.current; + }; + + const isEmpty = () => { + return !(text || spoilerText || anyMedia); + }; + + const isClickOutside = (e: MouseEvent | React.MouseEvent) => { + return ![ + // List of elements that shouldn't collapse the composer when clicked + // FIXME: Make this less brittle + getClickableArea(), + document.querySelector('.privacy-dropdown__dropdown'), + document.querySelector('.emoji-picker-dropdown__menu'), + document.getElementById('modal-overlay'), + ].some(element => element?.contains(e.target as any)); + }; + + const handleClick = useCallback((e: MouseEvent | React.MouseEvent) => { + if (isEmpty() && isClickOutside(e)) { + handleClickOutside(); + } + }, []); + + const handleClickOutside = () => { + setComposeFocused(false); + }; + + const handleComposeFocus = () => { + setComposeFocused(true); + }; + + const handleSubmit = () => { + if (text !== autosuggestTextareaRef.current?.textarea?.value) { + // Something changed the text inside the textarea (e.g. browser extensions like Grammarly) + // Update the state to match the current text + dispatch(changeCompose(id, autosuggestTextareaRef.current!.textarea!.value)); + } + + // Submit disabled: + const fulltext = [spoilerText, countableText(text)].join(''); + + if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { + return; + } + + dispatch(submitCompose(id, history)); + }; + + const onSuggestionsClearRequested = () => { + dispatch(clearComposeSuggestions()); + }; + + const onSuggestionsFetchRequested = (token: string | number) => { + dispatch(fetchComposeSuggestions(token as string)); + }; + + const onSuggestionSelected = (tokenStart: number, token: string | null, value: string | undefined) => { + if (value) dispatch(selectComposeSuggestion(tokenStart, token, value, ['text'])); + }; + + const onSpoilerSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => { + dispatch(selectComposeSuggestion(tokenStart, token, value, ['spoiler_text'])); + }; + + const handleChangeSpoilerText: React.ChangeEventHandler = (e) => { + dispatch(changeComposeSpoilerText(id, e.target.value)); + }; + + const setCursor = (start: number, end: number = start) => { + if (!autosuggestTextareaRef.current?.textarea) return; + autosuggestTextareaRef.current.textarea.setSelectionRange(start, end); + }; + + const handleEmojiPick = (data: Emoji) => { + const position = autosuggestTextareaRef.current!.textarea!.selectionStart; + const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]); + + dispatch(insertEmojiCompose(id, position, data, needsSpace)); + }; + + const onPaste = (files: FileList) => { + dispatch(uploadCompose(id, files, intl)); + }; + + const focusSpoilerInput = () => { + spoilerTextRef.current?.input?.focus(); + }; + + const focusTextarea = () => { + autosuggestTextareaRef.current?.textarea?.focus(); + }; + + useEffect(() => { + const length = text.length; + document.addEventListener('click', handleClick, true); + + if (length > 0) { + setCursor(length); // Set cursor at end + } + + return () => { + document.removeEventListener('click', handleClick, true); + }; + }, []); + + useEffect(() => { + switch (spoiler) { + case true: focusSpoilerInput(); break; + case false: focusTextarea(); break; + } + }, [spoiler]); + + useEffect(() => { + if (typeof caretPosition === 'number') { + setCursor(caretPosition); + } + }, [focusDate]); + + const condensed = shouldCondense && !composeFocused && isEmpty() && !isUploading; + const disabled = isSubmitting; + const countedText = [spoilerText, countableText(text)].join(''); + const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia); + const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth); + + let publishText: string | JSX.Element = ''; + + if (isEditing) { + publishText = intl.formatMessage(messages.saveChanges); + } else if (privacy === 'direct') { + publishText = ( + <> + + {intl.formatMessage(messages.message)} + + ); + } else if (privacy === 'private') { + publishText = ( + <> + + {intl.formatMessage(messages.publish)} + + ); + } else { + publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); + } + + if (scheduledAt) { + publishText = intl.formatMessage(messages.schedule); + } + + return ( + + {scheduledStatusCount > 0 && ( + + + + ) }} + />) + } + /> + )} + + + + {!shouldCondense && } + + {!shouldCondense && } + +
+ +
+ + + { + !condensed && +
+ + + +
+ } +
+ + + +
+
+ {features.media && } + + {features.polls && } + {features.privacyScopes && } + {features.scheduledStatuses && } + {features.spoilers && } + {features.richText && } +
+ +
+ {maxTootChars && ( +
+ + +
+ )} + +
+
+
+ ); +}; + +export default ComposeForm; diff --git a/app/soapbox/features/compose/components/compose_form.js b/app/soapbox/features/compose/components/compose_form.js deleted file mode 100644 index da66083a6..000000000 --- a/app/soapbox/features/compose/components/compose_form.js +++ /dev/null @@ -1,402 +0,0 @@ -import classNames from 'clsx'; -import get from 'lodash/get'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { Link, withRouter } from 'react-router-dom'; -import { length } from 'stringz'; - -import AutosuggestInput from 'soapbox/components/autosuggest_input'; -import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea'; -import Icon from 'soapbox/components/icon'; -import { Button, Stack } from 'soapbox/components/ui'; -import { isMobile } from 'soapbox/is_mobile'; - -import PollForm from '../components/polls/poll-form'; -import ReplyMentions from '../components/reply_mentions'; -import UploadForm from '../components/upload_form'; -import Warning from '../components/warning'; -import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; -import MarkdownButtonContainer from '../containers/markdown_button_container'; -import PollButtonContainer from '../containers/poll_button_container'; -import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; -import QuotedStatusContainer from '../containers/quoted_status_container'; -import ReplyIndicatorContainer from '../containers/reply_indicator_container'; -import ScheduleButtonContainer from '../containers/schedule_button_container'; -import ScheduleFormContainer from '../containers/schedule_form_container'; -import SpoilerButtonContainer from '../containers/spoiler_button_container'; -import UploadButtonContainer from '../containers/upload_button_container'; -import WarningContainer from '../containers/warning_container'; -import { countableText } from '../util/counter'; - -import TextCharacterCounter from './text_character_counter'; -import VisualCharacterCounter from './visual_character_counter'; - -const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; - -const messages = defineMessages({ - placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' }, - pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic...' }, - spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, - publish: { id: 'compose_form.publish', defaultMessage: 'Post' }, - publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, - message: { id: 'compose_form.message', defaultMessage: 'Message' }, - schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' }, - saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' }, -}); - -export default @withRouter -class ComposeForm extends ImmutablePureComponent { - - state = { - composeFocused: false, - } - - static propTypes = { - intl: PropTypes.object.isRequired, - text: PropTypes.string.isRequired, - suggestions: ImmutablePropTypes.list, - spoiler: PropTypes.bool, - privacy: PropTypes.string, - spoilerText: PropTypes.string, - focusDate: PropTypes.instanceOf(Date), - caretPosition: PropTypes.number, - hasPoll: PropTypes.bool, - isSubmitting: PropTypes.bool, - isChangingUpload: PropTypes.bool, - isEditing: PropTypes.bool, - isUploading: PropTypes.bool, - onChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - onClearSuggestions: PropTypes.func.isRequired, - onFetchSuggestions: PropTypes.func.isRequired, - onSuggestionSelected: PropTypes.func.isRequired, - onChangeSpoilerText: PropTypes.func.isRequired, - onPaste: PropTypes.func.isRequired, - onPickEmoji: PropTypes.func.isRequired, - showSearch: PropTypes.bool, - anyMedia: PropTypes.bool, - shouldCondense: PropTypes.bool, - autoFocus: PropTypes.bool, - group: ImmutablePropTypes.map, - isModalOpen: PropTypes.bool, - clickableAreaRef: PropTypes.object, - scheduledAt: PropTypes.instanceOf(Date), - features: PropTypes.object.isRequired, - }; - - static defaultProps = { - showSearch: false, - }; - - handleChange = (e) => { - this.props.onChange(e.target.value); - } - - handleComposeFocus = () => { - this.setState({ - composeFocused: true, - }); - } - - handleKeyDown = (e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.handleSubmit(); - e.preventDefault(); // Prevent bubbling to other ComposeForm instances - } - } - - getClickableArea = () => { - const { clickableAreaRef } = this.props; - return clickableAreaRef ? clickableAreaRef.current : this.form; - } - - isEmpty = () => { - const { text, spoilerText, anyMedia } = this.props; - return !(text || spoilerText || anyMedia); - } - - isClickOutside = (e) => { - return ![ - // List of elements that shouldn't collapse the composer when clicked - // FIXME: Make this less brittle - this.getClickableArea(), - document.querySelector('.privacy-dropdown__dropdown'), - document.querySelector('.emoji-picker-dropdown__menu'), - document.getElementById('modal-overlay'), - ].some(element => element?.contains(e.target)); - } - - handleClick = (e) => { - if (this.isEmpty() && this.isClickOutside(e)) { - this.handleClickOutside(); - } - } - - handleClickOutside = () => { - this.setState({ - composeFocused: false, - }); - } - - handleSubmit = () => { - if (this.props.text !== this.autosuggestTextarea.textarea.value) { - // Something changed the text inside the textarea (e.g. browser extensions like Grammarly) - // Update the state to match the current text - this.props.onChange(this.autosuggestTextarea.textarea.value); - } - - // Submit disabled: - const { isSubmitting, isChangingUpload, isUploading, anyMedia, maxTootChars } = this.props; - const fulltext = [this.props.spoilerText, countableText(this.props.text)].join(''); - - if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) { - return; - } - - this.props.onSubmit(this.props.history ? this.props.history : null, this.props.group); - } - - onSuggestionsClearRequested = () => { - this.props.onClearSuggestions(); - } - - onSuggestionsFetchRequested = (token) => { - this.props.onFetchSuggestions(token); - } - - onSuggestionSelected = (tokenStart, token, value) => { - this.props.onSuggestionSelected(tokenStart, token, value, ['text']); - } - - onSpoilerSuggestionSelected = (tokenStart, token, value) => { - this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']); - } - - handleChangeSpoilerText = (e) => { - this.props.onChangeSpoilerText(e.target.value); - } - - setCursor = (start, end = start) => { - if (!this.autosuggestTextarea) return; - this.autosuggestTextarea.textarea.setSelectionRange(start, end); - } - - componentDidMount() { - const length = this.props.text.length; - document.addEventListener('click', this.handleClick, true); - - if (length > 0) { - this.setCursor(length); // Set cursor at end - } - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleClick, true); - } - - setAutosuggestTextarea = (c) => { - this.autosuggestTextarea = c; - } - - setForm = (c) => { - this.form = c; - } - - setSpoilerText = (c) => { - this.spoilerText = c; - } - - handleEmojiPick = (data) => { - const { text } = this.props; - const position = this.autosuggestTextarea.textarea.selectionStart; - const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]); - - this.props.onPickEmoji(position, data, needsSpace); - } - - focusSpoilerInput = () => { - const spoilerInput = get(this, ['spoilerText', 'input']); - if (spoilerInput) spoilerInput.focus(); - } - - focusTextarea = () => { - const textarea = get(this, ['autosuggestTextarea', 'textarea']); - if (textarea) textarea.focus(); - } - - maybeUpdateFocus = prevProps => { - const spoilerUpdated = this.props.spoiler !== prevProps.spoiler; - if (spoilerUpdated) { - switch (this.props.spoiler) { - case true: this.focusSpoilerInput(); break; - case false: this.focusTextarea(); break; - } - } - } - - maybeUpdateCursor = prevProps => { - const shouldUpdate = [ - // Autosuggest has been updated and - // the cursor position explicitly set - this.props.focusDate !== prevProps.focusDate, - typeof this.props.caretPosition === 'number', - ].every(Boolean); - - if (shouldUpdate) { - this.setCursor(this.props.caretPosition); - } - } - - componentDidUpdate(prevProps) { - this.maybeUpdateFocus(prevProps); - this.maybeUpdateCursor(prevProps); - } - - render() { - const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen, maxTootChars, scheduledStatusCount, features } = this.props; - const condensed = shouldCondense && !this.state.composeFocused && this.isEmpty() && !this.props.isUploading; - const disabled = this.props.isSubmitting; - const text = [this.props.spoilerText, countableText(this.props.text)].join(''); - const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > maxTootChars || (text.length !== 0 && text.trim().length === 0 && !anyMedia); - const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth); - - let publishText = ''; - - if (this.props.isEditing) { - publishText = intl.formatMessage(messages.saveChanges); - } else if (this.props.privacy === 'direct') { - publishText = ( - <> - - {intl.formatMessage(messages.message)} - - ); - } else if (this.props.privacy === 'private') { - publishText = ( - <> - - {intl.formatMessage(messages.publish)} - - ); - } else { - publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); - } - - if (this.props.scheduledAt) { - publishText = intl.formatMessage(messages.schedule); - } - - return ( - - {scheduledStatusCount > 0 && ( - - - - ) }} - />) - } - /> - )} - - - - {!shouldCondense && } - - {!shouldCondense && } - -
- -
- - - { - !condensed && -
- - - -
- } -
- - - -
-
- {features.media && } - - {features.polls && } - {features.privacyScopes && } - {features.scheduledStatuses && } - {features.spoilers && } - {features.richText && } -
- -
- {maxTootChars && ( -
- - -
- )} - -
-
-
- ); - } - -} diff --git a/app/soapbox/features/compose/components/markdown_button.tsx b/app/soapbox/features/compose/components/markdown_button.tsx index 7d0d56eb6..00a6681e5 100644 --- a/app/soapbox/features/compose/components/markdown_button.tsx +++ b/app/soapbox/features/compose/components/markdown_button.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { changeComposeContentType } from 'soapbox/actions/compose'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + import ComposeFormButton from './compose_form_button'; const messages = defineMessages({ @@ -9,12 +12,16 @@ const messages = defineMessages({ }); interface IMarkdownButton { - active?: boolean, - onClick: () => void, + composeId: string, } -const MarkdownButton: React.FC = ({ active, onClick }) => { +const MarkdownButton: React.FC = ({ composeId }) => { const intl = useIntl(); + const dispatch = useAppDispatch(); + + const active = useAppSelector((state) => state.compose.get(composeId)!.content_type === 'text/markdown'); + + const onClick = () => dispatch(changeComposeContentType(composeId, active ? 'text/plain' : 'text/markdown')); return ( void, } -const PollButton: React.FC = ({ active, unavailable, disabled, onClick }) => { +const PollButton: React.FC = ({ composeId, disabled }) => { const intl = useIntl(); + const dispatch = useAppDispatch(); + + const unavailable = useAppSelector((state) => state.compose.get(composeId)!.is_uploading); + const active = useAppSelector((state) => state.compose.get(composeId)!.poll !== null); + + const onClick = () => { + if (active) { + dispatch(removePoll(composeId)); + } else { + dispatch(addPoll(composeId)); + } + }; if (unavailable) { return null; diff --git a/app/soapbox/features/compose/components/polls/poll-form.tsx b/app/soapbox/features/compose/components/polls/poll-form.tsx index 9cf727081..0c5bb7a7f 100644 --- a/app/soapbox/features/compose/components/polls/poll-form.tsx +++ b/app/soapbox/features/compose/components/polls/poll-form.tsx @@ -26,6 +26,7 @@ const messages = defineMessages({ }); interface IOption { + composeId: string index: number maxChars: number numOptions: number @@ -35,21 +36,20 @@ interface IOption { title: string } -const Option = (props: IOption) => { - const { - index, - maxChars, - numOptions, - onChange, - onRemove, - onRemovePoll, - title, - } = props; - +const Option: React.FC = ({ + composeId, + index, + maxChars, + numOptions, + onChange, + onRemove, + onRemovePoll, + title, +}) => { const dispatch = useAppDispatch(); const intl = useIntl(); - const suggestions = useAppSelector((state) => state.compose.suggestions); + const suggestions = useAppSelector((state) => state.compose.get(composeId)!.suggestions); const handleOptionTitleChange = (event: React.ChangeEvent) => onChange(index, event.target.value); @@ -102,26 +102,30 @@ const Option = (props: IOption) => { ); }; -const PollForm = () => { +interface IPollForm { + composeId: string, +} + +const PollForm: React.FC = ({ composeId }) => { const dispatch = useAppDispatch(); const intl = useIntl(); const pollLimits = useAppSelector((state) => state.instance.getIn(['configuration', 'polls']) as any); - const options = useAppSelector((state) => state.compose.poll?.options); - const expiresIn = useAppSelector((state) => state.compose.poll?.expires_in); - const isMultiple = useAppSelector((state) => state.compose.poll?.multiple); + const options = useAppSelector((state) => state.compose.get(composeId)!.poll?.options); + const expiresIn = useAppSelector((state) => state.compose.get(composeId)!.poll?.expires_in); + const isMultiple = useAppSelector((state) => state.compose.get(composeId)!.poll?.multiple); const maxOptions = pollLimits.get('max_options'); const maxOptionChars = pollLimits.get('max_characters_per_option'); - const onRemoveOption = (index: number) => dispatch(removePollOption(index)); - const onChangeOption = (index: number, title: string) => dispatch(changePollOption(index, title)); - const handleAddOption = () => dispatch(addPollOption('')); + const onRemoveOption = (index: number) => dispatch(removePollOption(composeId, index)); + const onChangeOption = (index: number, title: string) => dispatch(changePollOption(composeId, index, title)); + const handleAddOption = () => dispatch(addPollOption(composeId, '')); const onChangeSettings = (expiresIn: string | number | undefined, isMultiple?: boolean) => - dispatch(changePollSettings(expiresIn, isMultiple)); + dispatch(changePollSettings(composeId, expiresIn, isMultiple)); const handleSelectDuration = (value: number) => onChangeSettings(value, isMultiple); const handleToggleMultiple = () => onChangeSettings(expiresIn, !isMultiple); - const onRemovePoll = () => dispatch(removePoll()); + const onRemovePoll = () => dispatch(removePoll(composeId)); if (!options) { return null; @@ -132,6 +136,7 @@ const PollForm = () => { {options.map((title: string, i: number) => (