diff --git a/app/soapbox/actions/__tests__/compose.test.ts b/app/soapbox/actions/__tests__/compose.test.ts index 88f8c8858..1579d63c9 100644 --- a/app/soapbox/actions/__tests__/compose.test.ts +++ b/app/soapbox/actions/__tests__/compose.test.ts @@ -2,6 +2,7 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutabl import { mockStore, rootState } from 'soapbox/jest/test-helpers'; import { InstanceRecord } from 'soapbox/normalizers'; +import { ReducerCompose } from 'soapbox/reducers/compose'; import { uploadCompose, submitCompose } from '../compose'; import { STATUS_CREATE_REQUEST } from '../statuses'; @@ -26,7 +27,8 @@ describe('uploadCompose()', () => { const state = rootState .set('me', '1234') - .set('instance', instance); + .set('instance', instance) + .setIn(['compose', 'home'], ReducerCompose()); store = mockStore(state); files = [{ @@ -43,7 +45,7 @@ describe('uploadCompose()', () => { } as unknown as IntlShape; const expectedActions = [ - { type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, + { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true }, { type: 'ALERT_SHOW', message: 'Image exceeds the current file size limit (10 Bytes)', @@ -51,10 +53,10 @@ describe('uploadCompose()', () => { actionLink: undefined, severity: 'error', }, - { type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true }, + { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true }, ]; - await store.dispatch(uploadCompose(files, mockIntl)); + await store.dispatch(uploadCompose('home', files, mockIntl)); const actions = store.getActions(); expect(actions).toEqual(expectedActions); @@ -78,7 +80,8 @@ describe('uploadCompose()', () => { const state = rootState .set('me', '1234') - .set('instance', instance); + .set('instance', instance) + .setIn(['compose', 'home'], ReducerCompose()); store = mockStore(state); files = [{ @@ -95,7 +98,7 @@ describe('uploadCompose()', () => { } as unknown as IntlShape; const expectedActions = [ - { type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, + { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true }, { type: 'ALERT_SHOW', message: 'Video exceeds the current file size limit (10 Bytes)', @@ -103,10 +106,10 @@ describe('uploadCompose()', () => { actionLink: undefined, severity: 'error', }, - { type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true }, + { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true }, ]; - await store.dispatch(uploadCompose(files, mockIntl)); + await store.dispatch(uploadCompose('home', files, mockIntl)); const actions = store.getActions(); expect(actions).toEqual(expectedActions); @@ -118,10 +121,10 @@ describe('submitCompose()', () => { it('inserts mentions from text', async() => { const state = rootState .set('me', '123') - .setIn(['compose', 'text'], '@alex hello @mkljczk@pl.fediverse.pl @gg@汉语/漢語.com alex@alexgleason.me'); + .setIn(['compose', 'home'], ReducerCompose({ text: '@alex hello @mkljczk@pl.fediverse.pl @gg@汉语/漢語.com alex@alexgleason.me' })); const store = mockStore(state); - await store.dispatch(submitCompose()); + await store.dispatch(submitCompose('home')); const actions = store.getActions(); const statusCreateRequest = actions.find(action => action.type === STATUS_CREATE_REQUEST); diff --git a/app/soapbox/actions/__tests__/statuses.test.ts b/app/soapbox/actions/__tests__/statuses.test.ts index 18cbc173b..68af7608f 100644 --- a/app/soapbox/actions/__tests__/statuses.test.ts +++ b/app/soapbox/actions/__tests__/statuses.test.ts @@ -121,6 +121,7 @@ describe('deleteStatus()', () => { version: '0.0.0', }, withRedraft: true, + id: 'compose-modal', }, { type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined }, ]; diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 804141cd8..ab4d47b97 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(); @@ -116,6 +105,7 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin dispatch({ type: COMPOSE_SET_STATUS, + id: 'compose-modal', status, rawText, explicitAddressing, @@ -126,8 +116,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, }); @@ -139,6 +130,7 @@ const replyCompose = (status: Status) => dispatch({ type: COMPOSE_REPLY, + id: 'compose-modal', status: status, account: state.accounts.get(state.me), explicitAddressing, @@ -147,22 +139,9 @@ 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, + id: 'compose-modal', }); const quoteCompose = (status: Status) => @@ -173,6 +152,7 @@ const quoteCompose = (status: Status) => dispatch({ type: COMPOSE_QUOTE, + id: 'compose-modal', status: status, account: state.accounts.get(state.me), explicitAddressing, @@ -183,16 +163,19 @@ const quoteCompose = (status: Status) => const cancelQuoteCompose = () => ({ type: COMPOSE_QUOTE_CANCEL, + id: 'compose-modal', }); -const resetCompose = () => ({ +const resetCompose = (composeId = 'compose-modal') => ({ type: COMPOSE_RESET, + id: composeId, }); const mentionCompose = (account: Account) => (dispatch: AppDispatch) => { dispatch({ type: COMPOSE_MENTION, + id: 'compose-modal', account: account, }); @@ -203,6 +186,7 @@ const directCompose = (account: Account) => (dispatch: AppDispatch) => { dispatch({ type: COMPOSE_DIRECT, + id: 'compose-modal', account: account, }); @@ -215,22 +199,23 @@ const directComposeById = (accountId: string) => dispatch({ type: COMPOSE_DIRECT, + id: 'compose-modal', account: account, }); 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 +223,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 +232,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)!; + + const status = compose.text; + const media = compose.media_attachments; + const statusId = compose.id; + let to = compose.to; - if (!validateSchedule(state)) { + if (!validateSchedule(state, composeId)) { dispatch(snackbar.error(messages.scheduleError)); return; } @@ -266,11 +253,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 +269,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 +292,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 +323,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); @@ -342,7 +332,7 @@ const uploadCompose = (files: FileList, intl: IntlShape) => return; } - dispatch(uploadComposeRequest()); + dispatch(uploadComposeRequest(composeId)); Array.from(files).forEach(async(f, i) => { if (media.size + i > attachmentLimit - 1) return; @@ -355,18 +345,18 @@ const uploadCompose = (files: FileList, intl: IntlShape) => const limit = formatBytes(maxImageSize); const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); dispatch(snackbar.error(message)); - dispatch(uploadComposeFail(true)); + dispatch(uploadComposeFail(composeId, 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)); + dispatch(uploadComposeFail(composeId, true)); return; } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) { const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration }); dispatch(snackbar.error(message)); - dispatch(uploadComposeFail(true)); + dispatch(uploadComposeFail(composeId, true)); return; } @@ -380,7 +370,7 @@ const uploadCompose = (files: FileList, intl: IntlShape) => const onUploadProgress = ({ loaded }: any) => { progress[i] = loaded; - dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); + dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total)); }; return dispatch(uploadMedia(data, onUploadProgress)) @@ -388,98 +378,107 @@ const uploadCompose = (files: FileList, intl: IntlShape) => // If server-side processing of the media attachment has not completed yet, // poll the server until it is, before showing the media attachment as uploaded if (status === 200) { - dispatch(uploadComposeSuccess(data, f)); + dispatch(uploadComposeSuccess(composeId, data, f)); } else if (status === 202) { const poll = () => { dispatch(fetchMedia(data.id)).then(({ status, data }) => { if (status === 200) { - dispatch(uploadComposeSuccess(data, f)); + dispatch(uploadComposeSuccess(composeId, data, f)); } else if (status === 206) { setTimeout(() => poll(), 1000); } - }).catch(error => dispatch(uploadComposeFail(error))); + }).catch(error => dispatch(uploadComposeFail(composeId, error))); }; poll(); } }); - }).catch(error => dispatch(uploadComposeFail(error))); + }).catch(error => dispatch(uploadComposeFail(composeId, error))); /* eslint-enable no-loop-func */ }); }; -const changeUploadCompose = (id: string, params: Record) => +const changeUploadCompose = (composeId: string, id: string, params: Record) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; - dispatch(changeUploadComposeRequest()); + dispatch(changeUploadComposeRequest(composeId)); dispatch(updateMedia(id, params)).then(response => { - dispatch(changeUploadComposeSuccess(response.data)); + dispatch(changeUploadComposeSuccess(composeId, response.data)); }).catch(error => { - dispatch(changeUploadComposeFail(id, error)); + dispatch(changeUploadComposeFail(composeId, id, error)); }); }; -const changeUploadComposeRequest = () => ({ +const changeUploadComposeRequest = (composeId: string) => ({ type: COMPOSE_UPLOAD_CHANGE_REQUEST, + id: composeId, skipLoading: true, }); -const changeUploadComposeSuccess = (media: APIEntity) => ({ +const changeUploadComposeSuccess = (composeId: string, media: APIEntity) => ({ type: COMPOSE_UPLOAD_CHANGE_SUCCESS, + id: composeId, media: media, skipLoading: true, }); -const changeUploadComposeFail = (id: string, error: AxiosError) => ({ +const changeUploadComposeFail = (composeId: string, id: string, error: AxiosError) => ({ type: COMPOSE_UPLOAD_CHANGE_FAIL, + composeId, id, error: error, skipLoading: true, }); -const uploadComposeRequest = () => ({ +const uploadComposeRequest = (composeId: string) => ({ type: COMPOSE_UPLOAD_REQUEST, + id: composeId, skipLoading: true, }); -const uploadComposeProgress = (loaded: number, total: number) => ({ +const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({ type: COMPOSE_UPLOAD_PROGRESS, + id: composeId, loaded: loaded, total: total, }); -const uploadComposeSuccess = (media: APIEntity, file: File) => ({ +const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({ type: COMPOSE_UPLOAD_SUCCESS, + id: composeId, media: media, file, skipLoading: true, }); -const uploadComposeFail = (error: AxiosError | true) => ({ +const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({ type: COMPOSE_UPLOAD_FAIL, + id: composeId, error: error, skipLoading: true, }); -const undoUploadCompose = (media_id: string) => ({ +const undoUploadCompose = (composeId: string, media_id: string) => ({ type: COMPOSE_UPLOAD_UNDO, + id: composeId, media_id: media_id, }); -const clearComposeSuggestions = () => { +const clearComposeSuggestions = (composeId: string) => { if (cancelFetchComposeSuggestionsAccounts) { cancelFetchComposeSuggestionsAccounts(); } return { type: COMPOSE_SUGGESTIONS_CLEAR, + id: composeId, }; }; -const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { +const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => { if (cancelFetchComposeSuggestionsAccounts) { - cancelFetchComposeSuggestionsAccounts(); + cancelFetchComposeSuggestionsAccounts(composeId); } api(getState).get('/api/v1/accounts/search', { cancelToken: new CancelToken(cancel => { @@ -492,7 +491,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => }, }).then(response => { dispatch(importFetchedAccounts(response.data)); - dispatch(readyComposeSuggestionsAccounts(token, response.data)); + dispatch(readyComposeSuggestionsAccounts(composeId, token, response.data)); }).catch(error => { if (!isCancel(error)) { dispatch(showAlertForError(error)); @@ -500,46 +499,48 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => }); }, 200, { leading: true, trailing: true }); -const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, token: string) => { +const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => { const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any); - dispatch(readyComposeSuggestionsEmojis(token, results)); + dispatch(readyComposeSuggestionsEmojis(composeId, token, results)); }; -const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, token: string) => { +const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => { const state = getState(); const currentTrends = state.trends.items; - dispatch(updateSuggestionTags(token, currentTrends)); + dispatch(updateSuggestionTags(composeId, token, currentTrends)); }; -const fetchComposeSuggestions = (token: string) => +const fetchComposeSuggestions = (composeId: string, token: string) => (dispatch: AppDispatch, getState: () => RootState) => { switch (token[0]) { case ':': - fetchComposeSuggestionsEmojis(dispatch, getState, token); + fetchComposeSuggestionsEmojis(dispatch, getState, composeId, token); break; case '#': - fetchComposeSuggestionsTags(dispatch, getState, token); + fetchComposeSuggestionsTags(dispatch, getState, composeId, token); break; default: - fetchComposeSuggestionsAccounts(dispatch, getState, token); + fetchComposeSuggestionsAccounts(dispatch, getState, composeId, token); break; } }; -const readyComposeSuggestionsEmojis = (token: string, emojis: Emoji[]) => ({ +const readyComposeSuggestionsEmojis = (composeId: string, token: string, emojis: Emoji[]) => ({ type: COMPOSE_SUGGESTIONS_READY, + id: composeId, token, emojis, }); -const readyComposeSuggestionsAccounts = (token: string, accounts: APIEntity[]) => ({ +const readyComposeSuggestionsAccounts = (composeId: string, token: string, accounts: APIEntity[]) => ({ type: COMPOSE_SUGGESTIONS_READY, + id: composeId, token, accounts, }); -const selectComposeSuggestion = (position: number, token: string | null, suggestion: AutoSuggestion, path: Array) => +const selectComposeSuggestion = (composeId: string, position: number, token: string | null, suggestion: AutoSuggestion, path: Array) => (dispatch: AppDispatch, getState: () => RootState) => { let completion, startPosition; @@ -558,6 +559,7 @@ const selectComposeSuggestion = (position: number, token: string | null, suggest dispatch({ type: COMPOSE_SUGGESTION_SELECT, + id: composeId, position: startPosition, token, completion, @@ -565,21 +567,23 @@ const selectComposeSuggestion = (position: number, token: string | null, suggest }); }; -const updateSuggestionTags = (token: string, currentTrends: ImmutableList) => ({ +const updateSuggestionTags = (composeId: string, token: string, currentTrends: ImmutableList) => ({ type: COMPOSE_SUGGESTION_TAGS_UPDATE, + id: composeId, token, currentTrends, }); -const updateTagHistory = (tags: string[]) => ({ +const updateTagHistory = (composeId: string, tags: string[]) => ({ type: COMPOSE_TAG_HISTORY_UPDATE, + id: composeId, 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'))) @@ -591,120 +595,124 @@ const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) => const newHistory = names.slice(0, 1000); tagHistory.set(me as string, newHistory); - dispatch(updateTagHistory(newHistory)); + dispatch(updateTagHistory(composeId, 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(resetCompose(composeId)); dispatch(openModal('COMPOSE')); - dispatch(changeCompose(text)); + dispatch(changeCompose(composeId, text)); }; -const addToMentions = (accountId: string) => +const addToMentions = (composeId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); const acct = state.accounts.get(accountId)!.acct; return dispatch({ type: COMPOSE_ADD_TO_MENTIONS, + id: composeId, account: acct, }); }; -const removeFromMentions = (accountId: string) => +const removeFromMentions = (composeId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); const acct = state.accounts.get(accountId)!.acct; return dispatch({ type: COMPOSE_REMOVE_FROM_MENTIONS, + id: composeId, account: acct, }); }; @@ -731,8 +739,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 +762,9 @@ export { COMPOSE_ADD_TO_MENTIONS, COMPOSE_REMOVE_FROM_MENTIONS, COMPOSE_SET_STATUS, - ensureComposeIsVisible, setComposeToStatus, changeCompose, replyCompose, - replyComposeWithConfirmation, cancelReplyCompose, quoteCompose, cancelQuoteCompose, @@ -790,15 +794,12 @@ export { selectComposeSuggestion, updateSuggestionTags, updateTagHistory, - mountCompose, - unmountCompose, changeComposeSensitivity, changeComposeSpoilerness, changeComposeContentType, changeComposeSpoilerText, changeComposeVisibility, insertEmojiCompose, - changeComposing, addPoll, removePoll, addSchedule, diff --git a/app/soapbox/components/announcements/reactions-bar.tsx b/app/soapbox/components/announcements/reactions-bar.tsx index 5cec53974..130db2d99 100644 --- a/app/soapbox/components/announcements/reactions-bar.tsx +++ b/app/soapbox/components/announcements/reactions-bar.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { TransitionMotion, spring } from 'react-motion'; import { Icon } from 'soapbox/components/ui'; -import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container'; +import EmojiPickerDropdown from 'soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown'; import { useSettings } from 'soapbox/hooks'; import Reaction from './reaction'; 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..9190c37d4 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('compose-modal')), + isEditing: state.compose.get('compose-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 2dbe0b181..546d162a1 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 094e1aaed..2679fdeab 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'; @@ -126,7 +126,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 cc6bfab0d..6527184db 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -39,7 +39,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..d4b6a4406 --- /dev/null +++ b/app/soapbox/features/compose/components/compose-form.tsx @@ -0,0 +1,367 @@ +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, useCompose, useFeatures } from 'soapbox/hooks'; +import { isMobile } from 'soapbox/is_mobile'; + +import EmojiPickerDropdown from '../components/emoji-picker/emoji-picker-dropdown'; +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 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 = useCompose(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(id)); + }; + + const onSuggestionsFetchRequested = (token: string | number) => { + dispatch(fetchComposeSuggestions(id, token as string)); + }; + + const onSuggestionSelected = (tokenStart: number, token: string | null, value: string | undefined) => { + if (value) dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['text'])); + }; + + const onSpoilerSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => { + dispatch(selectComposeSuggestion(id, 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 renderButtons = useCallback(() => ( +
+ {features.media && } + + {features.polls && } + {features.privacyScopes && } + {features.scheduledStatuses && } + {features.spoilers && } + {features.richText && } +
+ ), [features, id]); + + 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 && +
+ + + +
+ } +
+ + + +
+ {renderButtons()} + +
+ {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/emoji-picker/emoji-picker-dropdown.tsx b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown.tsx new file mode 100644 index 000000000..697f38c8c --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown.tsx @@ -0,0 +1,209 @@ +import classNames from 'clsx'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import React, { useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +// @ts-ignore +import Overlay from 'react-overlays/lib/Overlay'; +import { createSelector } from 'reselect'; + +import { useEmoji } from 'soapbox/actions/emojis'; +import { getSettings, changeSetting } from 'soapbox/actions/settings'; +import { IconButton } from 'soapbox/components/ui'; +import { EmojiPicker as EmojiPickerAsync } from 'soapbox/features/ui/util/async-components'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import EmojiPickerMenu from './emoji-picker-menu'; + +import type { Emoji as EmojiType } from 'soapbox/components/autosuggest_emoji'; +import type { RootState } from 'soapbox/store'; + +let EmojiPicker: any, Emoji: any; // load asynchronously + +const perLine = 8; +const lines = 2; + +const DEFAULTS = [ + '+1', + 'grinning', + 'kissing_heart', + 'heart_eyes', + 'laughing', + 'stuck_out_tongue_winking_eye', + 'sweat_smile', + 'joy', + 'yum', + 'disappointed', + 'thinking_face', + 'weary', + 'sob', + 'sunglasses', + 'heart', + 'ok_hand', +]; + +const getFrequentlyUsedEmojis = createSelector([ + (state: RootState) => state.settings.get('frequentlyUsedEmojis', ImmutableMap()), +], emojiCounters => { + let emojis = emojiCounters + .keySeq() + .sort((a: number, b: number) => emojiCounters.get(a) - emojiCounters.get(b)) + .reverse() + .slice(0, perLine * lines) + .toArray(); + + if (emojis.length < DEFAULTS.length) { + const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji)); + emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length)); + } + + return emojis; +}); + +const getCustomEmojis = createSelector([ + (state: RootState) => state.custom_emojis as ImmutableList>, +], emojis => emojis.filter((e) => e.get('visible_in_picker')).sort((a, b) => { + const aShort = a.get('shortcode')!.toLowerCase(); + const bShort = b.get('shortcode')!.toLowerCase(); + + if (aShort < bShort) { + return -1; + } else if (aShort > bShort) { + return 1; + } else { + return 0; + } +}) as ImmutableList>); + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' }, + emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' }, + custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, + recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, + search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, + people: { id: 'emoji_button.people', defaultMessage: 'People' }, + nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, + food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, + activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, + travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, + objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, + symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, + flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, +}); + +interface IEmojiPickerDropdown { + onPickEmoji: (data: EmojiType) => void, + button?: JSX.Element, +} + +const EmojiPickerDropdown: React.FC = ({ onPickEmoji, button }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const customEmojis = useAppSelector((state) => getCustomEmojis(state)); + const skinTone = useAppSelector((state) => getSettings(state).get('skinTone') as number); + const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state)); + + const [active, setActive] = useState(false); + const [loading, setLoading] = useState(false); + const [placement, setPlacement] = useState<'bottom' | 'top'>(); + + const target = useRef(null); + + const onSkinTone = (skinTone: number) => { + dispatch(changeSetting(['skinTone'], skinTone)); + }; + + const handlePickEmoji = (emoji: EmojiType) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + dispatch(useEmoji(emoji)); + + if (onPickEmoji) { + onPickEmoji(emoji); + } + }; + + const onShowDropdown: React.EventHandler = (e) => { + e.stopPropagation(); + + setActive(true); + + if (!EmojiPicker) { + setLoading(true); + + EmojiPickerAsync().then(EmojiMart => { + EmojiPicker = EmojiMart.Picker; + Emoji = EmojiMart.Emoji; + + setLoading(false); + }).catch(() => { + setLoading(false); + }); + } + + const { top } = (e.target as any).getBoundingClientRect(); + setPlacement(top * 2 < innerHeight ? 'bottom' : 'top'); + }; + + const onHideDropdown = () => { + setActive(false); + }; + + const onToggle: React.EventHandler = (e) => { + if (!loading && (!(e as React.KeyboardEvent).key || (e as React.KeyboardEvent).key === 'Enter')) { + if (active) { + onHideDropdown(); + } else { + onShowDropdown(e); + } + } + }; + + const handleKeyDown: React.KeyboardEventHandler = e => { + if (e.key === 'Escape') { + onHideDropdown(); + } + }; + + const title = intl.formatMessage(messages.emoji); + + return ( +
+
+ {button || } +
+ + + + +
+ ); +}; + +export { EmojiPicker, Emoji }; + +export default EmojiPickerDropdown; diff --git a/app/soapbox/features/compose/components/emoji-picker/emoji-picker-menu.tsx b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-menu.tsx new file mode 100644 index 000000000..7cb12e8f5 --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/emoji-picker-menu.tsx @@ -0,0 +1,170 @@ +import classNames from 'clsx'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { buildCustomEmojis } from '../../../emoji/emoji'; + +import { EmojiPicker } from './emoji-picker-dropdown'; +import ModifierPicker from './modifier-picker'; + +import type { Emoji } from 'soapbox/components/autosuggest_emoji'; + +const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png'); +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +const categoriesSort = [ + 'recent', + 'custom', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', +]; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' }, + emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' }, + custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, + recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, + search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, + people: { id: 'emoji_button.people', defaultMessage: 'People' }, + nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, + food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, + activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, + travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, + objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, + symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, + flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, +}); + +interface IEmojiPickerMenu { + customEmojis: ImmutableList>, + loading?: boolean, + onClose: () => void, + onPick: (emoji: Emoji) => void, + onSkinTone: (skinTone: number) => void, + skinTone?: number, + frequentlyUsedEmojis?: Array, + style?: React.CSSProperties, +} + +const EmojiPickerMenu: React.FC = ({ + customEmojis, + loading = true, + onClose, + onPick, + onSkinTone, + skinTone, + frequentlyUsedEmojis = [], + style = {}, +}) => { + const intl = useIntl(); + + const node = useRef(null); + + const [modifierOpen, setModifierOpen] = useState(false); + + const handleDocumentClick = useCallback(e => { + if (node.current && !node.current.contains(e.target)) { + onClose(); + } + }, []); + + const getI18n = () => { + return { + search: intl.formatMessage(messages.emoji_search), + notfound: intl.formatMessage(messages.emoji_not_found), + categories: { + search: intl.formatMessage(messages.search_results), + recent: intl.formatMessage(messages.recent), + people: intl.formatMessage(messages.people), + nature: intl.formatMessage(messages.nature), + foods: intl.formatMessage(messages.food), + activity: intl.formatMessage(messages.activity), + places: intl.formatMessage(messages.travel), + objects: intl.formatMessage(messages.objects), + symbols: intl.formatMessage(messages.symbols), + flags: intl.formatMessage(messages.flags), + custom: intl.formatMessage(messages.custom), + }, + }; + }; + + const handleClick = (emoji: any) => { + if (!emoji.native) { + emoji.native = emoji.colons; + } + + onClose(); + onPick(emoji); + }; + + const handleModifierOpen = () => { + setModifierOpen(true); + }; + + const handleModifierClose = () => { + setModifierOpen(false); + }; + + const handleModifierChange = (modifier: number) => { + onSkinTone(modifier); + }; + + useEffect(() => { + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + + return () => { + document.removeEventListener('click', handleDocumentClick, false); + document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any); + }; + }, []); + + if (loading) { + return
; + } + + const title = intl.formatMessage(messages.emoji); + + return ( +
+ + + +
+ ); +}; + +export default EmojiPickerMenu; diff --git a/app/soapbox/features/compose/components/emoji-picker/modifier-picker-menu.tsx b/app/soapbox/features/compose/components/emoji-picker/modifier-picker-menu.tsx new file mode 100644 index 000000000..b62053ca5 --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/modifier-picker-menu.tsx @@ -0,0 +1,73 @@ +import { supportsPassiveEvents } from 'detect-passive-events'; +import React, { useCallback, useEffect, useRef } from 'react'; + +import { Emoji } from './emoji-picker-dropdown'; + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; +const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png'); + +interface IModifierPickerMenu { + active: boolean, + onSelect: (modifier: number) => void, + onClose: () => void, +} + +const ModifierPickerMenu: React.FC = ({ active, onSelect, onClose }) => { + const node = useRef(null); + + const handleClick: React.MouseEventHandler = e => { + onSelect(+e.currentTarget.getAttribute('data-index')! * 1); + }; + + const handleDocumentClick = useCallback((e => { + if (node.current && !node.current.contains(e.target)) { + onClose(); + } + }), []); + + const attachListeners = () => { + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + }; + + const removeListeners = () => { + document.removeEventListener('click', handleDocumentClick, false); + document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any); + }; + + useEffect(() => { + return () => { + removeListeners(); + }; + }, []); + + useEffect(() => { + if (active) attachListeners(); + else removeListeners(); + }, [active]); + + return ( +
+ + + + + + +
+ ); +}; + +export default ModifierPickerMenu; diff --git a/app/soapbox/features/compose/components/emoji-picker/modifier-picker.tsx b/app/soapbox/features/compose/components/emoji-picker/modifier-picker.tsx new file mode 100644 index 000000000..a84b71122 --- /dev/null +++ b/app/soapbox/features/compose/components/emoji-picker/modifier-picker.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { Emoji } from './emoji-picker-dropdown'; +import ModifierPickerMenu from './modifier-picker-menu'; + +const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png'); + +interface IModifierPicker { + active: boolean, + modifier?: number, + onOpen: () => void, + onClose: () => void, + onChange: (skinTone: number) => void, +} + +const ModifierPicker: React.FC = ({ active, modifier, onOpen, onClose, onChange }) => { + const handleClick = () => { + if (active) { + onClose(); + } else { + onOpen(); + } + }; + + const handleSelect = (modifier: number) => { + onChange(modifier); + onClose(); + }; + + return ( +
+ + +
+ ); +}; + +export default ModifierPicker; diff --git a/app/soapbox/features/compose/components/emoji_picker_dropdown.js b/app/soapbox/features/compose/components/emoji_picker_dropdown.js deleted file mode 100644 index 9deddd623..000000000 --- a/app/soapbox/features/compose/components/emoji_picker_dropdown.js +++ /dev/null @@ -1,397 +0,0 @@ -import classNames from 'clsx'; -import { supportsPassiveEvents } from 'detect-passive-events'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { defineMessages, injectIntl } from 'react-intl'; -import Overlay from 'react-overlays/lib/Overlay'; - -import { IconButton } from 'soapbox/components/ui'; - -import { buildCustomEmojis } from '../../emoji/emoji'; -import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; - -const messages = defineMessages({ - emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, - emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' }, - emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' }, - custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, - recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, - search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, - people: { id: 'emoji_button.people', defaultMessage: 'People' }, - nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, - food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, - activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, - travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, - objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, - symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, - flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, -}); - -let EmojiPicker, Emoji; // load asynchronously - -const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png'); -const listenerOptions = supportsPassiveEvents ? { passive: true } : false; - -const categoriesSort = [ - 'recent', - 'custom', - 'people', - 'nature', - 'foods', - 'activity', - 'places', - 'objects', - 'symbols', - 'flags', -]; - -class ModifierPickerMenu extends React.PureComponent { - - static propTypes = { - active: PropTypes.bool, - onSelect: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - }; - - handleClick = e => { - this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); - } - - componentDidUpdate(prevProps) { - if (this.props.active) { - this.attachListeners(); - } else { - this.removeListeners(); - } - } - - componentWillUnmount() { - this.removeListeners(); - } - - handleDocumentClick = e => { - if (this.node && !this.node.contains(e.target)) { - this.props.onClose(); - } - } - - attachListeners() { - document.addEventListener('click', this.handleDocumentClick, false); - document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - removeListeners() { - document.removeEventListener('click', this.handleDocumentClick, false); - document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - setRef = c => { - this.node = c; - } - - render() { - const { active } = this.props; - - return ( -
- - - - - - -
- ); - } - -} - -class ModifierPicker extends React.PureComponent { - - static propTypes = { - active: PropTypes.bool, - modifier: PropTypes.number, - onChange: PropTypes.func, - onClose: PropTypes.func, - onOpen: PropTypes.func, - }; - - handleClick = () => { - if (this.props.active) { - this.props.onClose(); - } else { - this.props.onOpen(); - } - } - - handleSelect = modifier => { - this.props.onChange(modifier); - this.props.onClose(); - } - - render() { - const { active, modifier } = this.props; - - return ( -
- - -
- ); - } - -} - -@injectIntl -class EmojiPickerMenu extends React.PureComponent { - - static propTypes = { - custom_emojis: ImmutablePropTypes.list, - frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), - loading: PropTypes.bool, - onClose: PropTypes.func.isRequired, - onPick: PropTypes.func.isRequired, - style: PropTypes.object, - placement: PropTypes.string, - arrowOffsetLeft: PropTypes.string, - arrowOffsetTop: PropTypes.string, - intl: PropTypes.object.isRequired, - skinTone: PropTypes.number.isRequired, - onSkinTone: PropTypes.func.isRequired, - }; - - static defaultProps = { - style: {}, - loading: true, - frequentlyUsedEmojis: [], - }; - - state = { - modifierOpen: false, - placement: null, - }; - - handleDocumentClick = e => { - if (this.node && !this.node.contains(e.target)) { - this.props.onClose(); - } - } - - componentDidMount() { - document.addEventListener('click', this.handleDocumentClick, false); - document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleDocumentClick, false); - document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - setRef = c => { - this.node = c; - } - - getI18n = () => { - const { intl } = this.props; - - return { - search: intl.formatMessage(messages.emoji_search), - notfound: intl.formatMessage(messages.emoji_not_found), - categories: { - search: intl.formatMessage(messages.search_results), - recent: intl.formatMessage(messages.recent), - people: intl.formatMessage(messages.people), - nature: intl.formatMessage(messages.nature), - foods: intl.formatMessage(messages.food), - activity: intl.formatMessage(messages.activity), - places: intl.formatMessage(messages.travel), - objects: intl.formatMessage(messages.objects), - symbols: intl.formatMessage(messages.symbols), - flags: intl.formatMessage(messages.flags), - custom: intl.formatMessage(messages.custom), - }, - }; - } - - handleClick = emoji => { - if (!emoji.native) { - emoji.native = emoji.colons; - } - - this.props.onClose(); - this.props.onPick(emoji); - } - - handleModifierOpen = () => { - this.setState({ modifierOpen: true }); - } - - handleModifierClose = () => { - this.setState({ modifierOpen: false }); - } - - handleModifierChange = modifier => { - this.props.onSkinTone(modifier); - } - - render() { - const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props; - - if (loading) { - return
; - } - - const title = intl.formatMessage(messages.emoji); - const { modifierOpen } = this.state; - - return ( -
- - - -
- ); - } - -} - -export default @injectIntl -class EmojiPickerDropdown extends React.PureComponent { - - static propTypes = { - custom_emojis: ImmutablePropTypes.list, - frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), - intl: PropTypes.object.isRequired, - onPickEmoji: PropTypes.func.isRequired, - onSkinTone: PropTypes.func.isRequired, - skinTone: PropTypes.number.isRequired, - button: PropTypes.node, - }; - - state = { - active: false, - loading: false, - }; - - setRef = (c) => { - this.dropdown = c; - } - - onShowDropdown = (e) => { - e.stopPropagation(); - - this.setState({ active: true }); - - if (!EmojiPicker) { - this.setState({ loading: true }); - - EmojiPickerAsync().then(EmojiMart => { - EmojiPicker = EmojiMart.Picker; - Emoji = EmojiMart.Emoji; - - this.setState({ loading: false }); - }).catch(() => { - this.setState({ loading: false }); - }); - } - - const { top } = e.target.getBoundingClientRect(); - this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); - } - - onHideDropdown = () => { - this.setState({ active: false }); - } - - onToggle = (e) => { - if (!this.state.loading && (!e.key || e.key === 'Enter')) { - if (this.state.active) { - this.onHideDropdown(); - } else { - this.onShowDropdown(e); - } - } - } - - handleKeyDown = e => { - if (e.key === 'Escape') { - this.onHideDropdown(); - } - } - - setTargetRef = c => { - this.target = c; - } - - findTarget = () => { - return this.target; - } - - render() { - const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props; - const title = intl.formatMessage(messages.emoji); - const { active, loading, placement } = this.state; - - return ( -
-
- {button || } -
- - - - -
- ); - } - -} diff --git a/app/soapbox/features/compose/components/markdown_button.tsx b/app/soapbox/features/compose/components/markdown_button.tsx index 7d0d56eb6..0f44d8786 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, useCompose } 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 = useCompose(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 compose = useCompose(composeId); + + const unavailable = compose.is_uploading; + const active = compose.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..4daf54048 100644 --- a/app/soapbox/features/compose/components/polls/poll-form.tsx +++ b/app/soapbox/features/compose/components/polls/poll-form.tsx @@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { addPollOption, changePollOption, changePollSettings, clearComposeSuggestions, fetchComposeSuggestions, removePoll, removePollOption, selectComposeSuggestion } from 'soapbox/actions/compose'; import AutosuggestInput from 'soapbox/components/autosuggest_input'; import { Button, Divider, HStack, Stack, Text, Toggle } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useCompose } from 'soapbox/hooks'; import DurationSelector from './duration-selector'; @@ -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 = useCompose(composeId).suggestions; const handleOptionTitleChange = (event: React.ChangeEvent) => onChange(index, event.target.value); @@ -61,13 +61,13 @@ const Option = (props: IOption) => { } }; - const onSuggestionsClearRequested = () => dispatch(clearComposeSuggestions()); + const onSuggestionsClearRequested = () => dispatch(clearComposeSuggestions(composeId)); - const onSuggestionsFetchRequested = (token: string) => dispatch(fetchComposeSuggestions(token)); + const onSuggestionsFetchRequested = (token: string) => dispatch(fetchComposeSuggestions(composeId, token)); const onSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => { if (token && typeof token === 'string') { - dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index])); + dispatch(selectComposeSuggestion(composeId, tokenStart, token, value, ['poll', 'options', index])); } }; @@ -102,26 +102,32 @@ const Option = (props: IOption) => { ); }; -const PollForm = () => { +interface IPollForm { + composeId: string, +} + +const PollForm: React.FC = ({ composeId }) => { const dispatch = useAppDispatch(); const intl = useIntl(); + const compose = useCompose(composeId); + 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 = compose.poll?.options; + const expiresIn = compose.poll?.expires_in; + const isMultiple = compose.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 +138,7 @@ const PollForm = () => { {options.map((title: string, i: number) => (