Allow multiple compose forms

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-09-10 23:52:06 +02:00
parent 6a1c3a7975
commit a9b8371086
43 changed files with 1266 additions and 1493 deletions

View File

@ -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,

View File

@ -46,8 +46,8 @@ interface IAutosuggesteTextarea {
onSuggestionsClearRequested: () => void,
onSuggestionsFetchRequested: (token: string | number) => void,
onChange: React.ChangeEventHandler<HTMLTextAreaElement>,
onKeyUp: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>,
onPaste: (files: FileList) => void,
autoFocus: boolean,
onFocus: () => void,

View File

@ -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) => ({

View File

@ -123,18 +123,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
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<IStatusActionBar> = ({
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');
}

View File

@ -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<IStatus> = (props) => {
const handleHotkeyReply = (e?: KeyboardEvent): void => {
e?.preventDefault();
dispatch(replyComposeWithConfirmation(actualStatus, intl));
dispatch(replyCompose(actualStatus));
};
const handleHotkeyFavourite = (): void => {

View File

@ -37,7 +37,7 @@ interface IStack extends React.HTMLAttributes<HTMLDivElement> {
}
/** Vertical stack of child elements. */
const Stack: React.FC<IStack> = React.forwardRef((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => {
const Stack = React.forwardRef<HTMLDivElement, IStack>((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => {
const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props;
return (

View File

@ -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<HTMLDivElement>,
}
const ComposeForm: React.FC<IComposeForm> = ({ 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<AutosuggestInput>(null);
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (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<HTMLInputElement> = (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 = (
<>
<Icon src={require('@tabler/icons/mail.svg')} />
{intl.formatMessage(messages.message)}
</>
);
} else if (privacy === 'private') {
publishText = (
<>
<Icon src={require('@tabler/icons/lock.svg')} />
{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 (
<Stack className='w-full' space={1} ref={formRef} onClick={handleClick}>
{scheduledStatusCount > 0 && (
<Warning
message={(
<FormattedMessage
id='compose_form.scheduled_statuses.message'
defaultMessage='You have scheduled posts. {click_here} to see them.'
values={{ click_here: (
<Link to='/scheduled_statuses'>
<FormattedMessage
id='compose_form.scheduled_statuses.click_here'
defaultMessage='Click here'
/>
</Link>
) }}
/>)
}
/>
)}
<WarningContainer composeId={id} />
{!shouldCondense && <ReplyIndicatorContainer composeId={id} />}
{!shouldCondense && <ReplyMentions composeId={id} />}
<div
className={classNames({
'relative transition-height': true,
'hidden': !spoiler,
})}
>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={spoilerText}
onChange={handleChangeSpoilerText}
onKeyDown={handleKeyDown}
disabled={!spoiler}
ref={spoilerTextRef}
suggestions={suggestions}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='border-none shadow-none px-0 py-2 text-base'
autoFocus
/>
</div>
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
placeholder={intl.formatMessage(hasPoll ? messages.pollPlaceholder : messages.placeholder)}
disabled={disabled}
value={text}
onChange={handleChange}
suggestions={suggestions}
onKeyDown={handleKeyDown}
onFocus={handleComposeFocus}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSuggestionSelected}
onPaste={onPaste}
autoFocus={shouldAutoFocus}
condensed={condensed}
id='compose-textarea'
>
{
!condensed &&
<div className='compose-form__modifiers'>
<UploadForm composeId={id} />
<PollForm composeId={id} />
<ScheduleFormContainer composeId={id} />
</div>
}
</AutosuggestTextarea>
<QuotedStatusContainer composeId={id} />
<div
className={classNames('flex flex-wrap items-center justify-between', {
'hidden': condensed,
})}
>
<div className='flex items-center space-x-2'>
{features.media && <UploadButtonContainer composeId={id} />}
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
{features.polls && <PollButton composeId={id} />}
{features.privacyScopes && <PrivacyDropdown composeId={id} />}
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
{features.spoilers && <SpoilerButton composeId={id} />}
{features.richText && <MarkdownButton composeId={id} />}
</div>
<div className='flex items-center space-x-4 ml-auto'>
{maxTootChars && (
<div className='flex items-center space-x-1'>
<TextCharacterCounter max={maxTootChars} text={text} />
<VisualCharacterCounter max={maxTootChars} text={text} />
</div>
)}
<Button theme='primary' text={publishText} onClick={handleSubmit} disabled={disabledButton} />
</div>
</div>
</Stack>
);
};
export default ComposeForm;

View File

@ -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 = (
<>
<Icon src={require('@tabler/icons/mail.svg')} />
{intl.formatMessage(messages.message)}
</>
);
} else if (this.props.privacy === 'private') {
publishText = (
<>
<Icon src={require('@tabler/icons/lock.svg')} />
{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 (
<Stack className='w-full' space={1} ref={this.setForm} onClick={this.handleClick}>
{scheduledStatusCount > 0 && (
<Warning
message={(
<FormattedMessage
id='compose_form.scheduled_statuses.message'
defaultMessage='You have scheduled posts. {click_here} to see them.'
values={{ click_here: (
<Link to='/scheduled_statuses'>
<FormattedMessage
id='compose_form.scheduled_statuses.click_here'
defaultMessage='Click here'
/>
</Link>
) }}
/>)
}
/>
)}
<WarningContainer />
{!shouldCondense && <ReplyIndicatorContainer />}
{!shouldCondense && <ReplyMentions />}
<div
className={classNames({
'relative transition-height': true,
'hidden': !this.props.spoiler,
})}
>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
disabled={!this.props.spoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='border-none shadow-none px-0 py-2 text-base'
autoFocus
/>
</div>
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? null : this.setAutosuggestTextarea}
placeholder={intl.formatMessage(this.props.hasPoll ? messages.pollPlaceholder : messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyDown={this.handleKeyDown}
onFocus={this.handleComposeFocus}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={shouldAutoFocus}
condensed={condensed}
id='compose-textarea'
>
{
!condensed &&
<div className='compose-form__modifiers'>
<UploadForm />
<PollForm />
<ScheduleFormContainer />
</div>
}
</AutosuggestTextarea>
<QuotedStatusContainer />
<div
className={classNames('flex flex-wrap items-center justify-between', {
'hidden': condensed,
})}
>
<div className='flex items-center space-x-2'>
{features.media && <UploadButtonContainer />}
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
{features.polls && <PollButtonContainer />}
{features.privacyScopes && <PrivacyDropdownContainer />}
{features.scheduledStatuses && <ScheduleButtonContainer />}
{features.spoilers && <SpoilerButtonContainer />}
{features.richText && <MarkdownButtonContainer />}
</div>
<div className='flex items-center space-x-4 ml-auto'>
{maxTootChars && (
<div className='flex items-center space-x-1'>
<TextCharacterCounter max={maxTootChars} text={text} />
<VisualCharacterCounter max={maxTootChars} text={text} />
</div>
)}
<Button theme='primary' text={publishText} onClick={this.handleSubmit} disabled={disabledButton} />
</div>
</div>
</Stack>
);
}
}

View File

@ -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<IMarkdownButton> = ({ active, onClick }) => {
const MarkdownButton: React.FC<IMarkdownButton> = ({ 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 (
<ComposeFormButton

View File

@ -1,6 +1,9 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { addPoll, removePoll } from 'soapbox/actions/compose';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button';
const messages = defineMessages({
@ -9,14 +12,24 @@ const messages = defineMessages({
});
interface IPollButton {
composeId: string
disabled?: boolean,
unavailable?: boolean,
active?: boolean,
onClick: () => void,
}
const PollButton: React.FC<IPollButton> = ({ active, unavailable, disabled, onClick }) => {
const PollButton: React.FC<IPollButton> = ({ 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;

View File

@ -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<IOption> = ({
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<HTMLInputElement>) => onChange(index, event.target.value);
@ -102,26 +102,30 @@ const Option = (props: IOption) => {
);
};
const PollForm = () => {
interface IPollForm {
composeId: string,
}
const PollForm: React.FC<IPollForm> = ({ 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 = () => {
<Stack space={2}>
{options.map((title: string, i: number) => (
<Option
composeId={composeId}
title={title}
key={i}
index={i}

View File

@ -6,8 +6,12 @@ import { spring } from 'react-motion';
// @ts-ignore
import Overlay from 'react-overlays/lib/Overlay';
import { changeComposeVisibility } from 'soapbox/actions/compose';
import { closeModal, openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon';
import { IconButton } from 'soapbox/components/ui';
import { isUserTouching } from 'soapbox/is_mobile';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import Motion from '../../ui/util/optional_motion';
@ -50,7 +54,7 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
const handleKeyDown: React.KeyboardEventHandler = e => {
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => item.value === value);
let element = null;
let element: ChildNode | null | undefined = null;
switch (e.key) {
case 'Escape':
@ -136,27 +140,20 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
};
interface IPrivacyDropdown {
isUserTouching: () => boolean,
isModalOpen: boolean,
onModalOpen: (opts: any) => void,
onModalClose: () => void,
value: string,
onChange: (value: string | null) => void,
unavailable: boolean,
composeId: string,
}
const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
isUserTouching,
onChange,
onModalClose,
onModalOpen,
value,
unavailable,
composeId,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const node = useRef<HTMLDivElement>(null);
const activeElement = useRef<HTMLElement | null>(null);
const value = useAppSelector(state => state.compose.get(composeId)!.privacy);
const unavailable = useAppSelector(state => !!state.compose.get(composeId)!.id);
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState('bottom');
@ -167,6 +164,12 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
{ icon: require('@tabler/icons/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) },
];
const onChange = (value: string | null) => value && dispatch(changeComposeVisibility(composeId, value));
const onModalOpen = (props: Record<string, any>) => dispatch(openModal('ACTIONS', props));
const onModalClose = () => dispatch(closeModal('ACTIONS'));
const handleToggle: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (isUserTouching()) {
if (open) {

View File

@ -10,12 +10,16 @@ import { getFeatures } from 'soapbox/utils/features';
import type { Status as StatusEntity } from 'soapbox/types/entities';
const ReplyMentions: React.FC = () => {
interface IReplyMentions {
composeId: string,
}
const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
const dispatch = useDispatch();
const instance = useAppSelector((state) => state.instance);
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.in_reply_to! }));
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.get(composeId)!.in_reply_to! }));
const to = useAppSelector((state) => state.compose.to);
const to = useAppSelector((state) => state.compose.get(composeId)!.to);
const account = useAppSelector((state) => state.accounts.get(state.me));
const { explicitAddressing } = getFeatures(instance);

View File

@ -1,6 +1,9 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { addSchedule, removeSchedule } from 'soapbox/actions/compose';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button';
const messages = defineMessages({
@ -9,17 +12,23 @@ const messages = defineMessages({
});
interface IScheduleButton {
disabled: boolean,
active: boolean,
unavailable: boolean,
onClick: () => void,
composeId: string,
disabled?: boolean,
}
const ScheduleButton: React.FC<IScheduleButton> = ({ active, unavailable, disabled, onClick }) => {
const ScheduleButton: React.FC<IScheduleButton> = ({ composeId, disabled }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const active = useAppSelector((state) => !!state.compose.get(composeId)!.schedule);
const unavailable = useAppSelector((state) => !!state.compose.get(composeId)!.id);
const handleClick = () => {
onClick();
if (active) {
dispatch(removeSchedule(composeId));
} else {
dispatch(addSchedule(composeId));
}
};
if (unavailable) {

View File

@ -27,19 +27,23 @@ const messages = defineMessages({
remove: { id: 'schedule.remove', defaultMessage: 'Remove schedule' },
});
const ScheduleForm: React.FC = () => {
export interface IScheduleForm {
composeId: string,
}
const ScheduleForm: React.FC<IScheduleForm> = ({ composeId }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const scheduledAt = useAppSelector((state) => state.compose.schedule);
const scheduledAt = useAppSelector((state) => state.compose.get(composeId)!.schedule);
const active = !!scheduledAt;
const onSchedule = (date: Date) => {
dispatch(setSchedule(date));
dispatch(setSchedule(composeId, date));
};
const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => {
dispatch(removeSchedule());
dispatch(removeSchedule(composeId));
e.preventDefault();
};

View File

@ -10,16 +10,20 @@ const messages = defineMessages({
unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' },
});
interface ISensitiveButton {
composeId: string,
}
/** Button to mark own media as sensitive. */
const SensitiveButton: React.FC = () => {
const SensitiveButton: React.FC<ISensitiveButton> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const active = useAppSelector(state => state.compose.sensitive === true);
const disabled = useAppSelector(state => state.compose.spoiler === true);
const active = useAppSelector(state => state.compose.get(composeId)!.sensitive === true);
const disabled = useAppSelector(state => state.compose.get(composeId)!.spoiler === true);
const onClick = () => {
dispatch(changeComposeSensitivity());
dispatch(changeComposeSensitivity(composeId));
};
return (

View File

@ -1,6 +1,9 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeComposeSpoilerness } from 'soapbox/actions/compose';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button';
const messages = defineMessages({
@ -9,12 +12,17 @@ const messages = defineMessages({
});
interface ISpoilerButton {
active?: boolean,
onClick: () => void,
composeId: string,
}
const SpoilerButton: React.FC<ISpoilerButton> = ({ active, onClick }) => {
const SpoilerButton: React.FC<ISpoilerButton> = ({ composeId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const active = useAppSelector((state) => state.compose.get(composeId)!.spoiler);
const onClick = () =>
dispatch(changeComposeSpoilerness(composeId));
return (
<ComposeFormButton

View File

@ -3,10 +3,14 @@ import React from 'react';
import UploadProgress from 'soapbox/components/upload-progress';
import { useAppSelector } from 'soapbox/hooks';
interface IComposeUploadProgress {
composeId: string,
}
/** File upload progress bar for post composer. */
const ComposeUploadProgress = () => {
const active = useAppSelector((state) => state.compose.is_uploading);
const progress = useAppSelector((state) => state.compose.progress);
const ComposeUploadProgress: React.FC<IComposeUploadProgress> = ({ composeId }) => {
const active = useAppSelector((state) => state.compose.get(composeId)!.is_uploading);
const progress = useAppSelector((state) => state.compose.get(composeId)!.progress);
if (!active) {
return null;

View File

@ -1,5 +1,5 @@
import React, { useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, IntlShape, useIntl } from 'react-intl';
import { IconButton } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
@ -14,12 +14,12 @@ const onlyImages = (types: ImmutableList<string>) => {
return Boolean(types && types.every(type => type.startsWith('image/')));
};
interface IUploadButton {
export interface IUploadButton {
disabled?: boolean,
unavailable?: boolean,
onSelectFile: (files: FileList) => void,
onSelectFile: (files: FileList, intl: IntlShape) => void,
style?: React.CSSProperties,
resetFileKey: number,
resetFileKey: number | null,
}
const UploadButton: React.FC<IUploadButton> = ({
@ -35,7 +35,7 @@ const UploadButton: React.FC<IUploadButton> = ({
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.files?.length) {
onSelectFile(e.target.files);
onSelectFile(e.target.files, intl);
}
};

View File

@ -9,15 +9,19 @@ import UploadContainer from '../containers/upload_container';
import type { Attachment as AttachmentEntity } from 'soapbox/types/entities';
const UploadForm = () => {
const mediaIds = useAppSelector((state) => state.compose.media_attachments.map((item: AttachmentEntity) => item.id));
interface IUploadForm {
composeId: string,
}
const UploadForm: React.FC<IUploadForm> = ({ composeId }) => {
const mediaIds = useAppSelector((state) => state.compose.get(composeId)!.media_attachments.map((item: AttachmentEntity) => item.id));
const classes = classNames('compose-form__uploads-wrapper', {
'contains-media': mediaIds.size !== 0,
});
return (
<div className='compose-form__upload-wrapper'>
<UploadProgress />
<UploadProgress composeId={composeId} />
<div className={classes}>
{mediaIds.map((id: string) => (
@ -25,7 +29,7 @@ const UploadForm = () => {
))}
</div>
{!mediaIds.isEmpty() && <SensitiveButton />}
{!mediaIds.isEmpty() && <SensitiveButton composeId={composeId} />}
</div>
);
};

View File

@ -1,87 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import {
changeCompose,
submitCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose,
uploadCompose,
} from 'soapbox/actions/compose';
import { getFeatures } from 'soapbox/utils/features';
import ComposeForm from '../components/compose_form';
const mapStateToProps = state => {
const instance = state.get('instance');
return {
text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['compose', 'spoiler']),
spoilerText: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']),
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),
hasPoll: !!state.getIn(['compose', 'poll']),
isSubmitting: state.getIn(['compose', 'is_submitting']),
isEditing: state.getIn(['compose', 'id']) !== null,
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'COMPOSE'),
maxTootChars: state.getIn(['instance', 'configuration', 'statuses', 'max_characters']),
scheduledAt: state.getIn(['compose', 'schedule']),
scheduledStatusCount: state.get('scheduled_statuses').size,
features: getFeatures(instance),
};
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onChange(text) {
dispatch(changeCompose(text));
},
onSubmit(router, group) {
dispatch(submitCompose(router, group));
},
onClearSuggestions() {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions(token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected(position, token, suggestion, path) {
dispatch(selectComposeSuggestion(position, token, suggestion, path));
},
onChangeSpoilerText(checked) {
dispatch(changeComposeSpoilerText(checked));
},
onPaste(files) {
dispatch(uploadCompose(files, intl));
},
onPickEmoji(position, data, needsSpace) {
dispatch(insertEmojiCompose(position, data, needsSpace));
},
});
function mergeProps(stateProps, dispatchProps, ownProps) {
return Object.assign({}, ownProps, {
...stateProps,
...dispatchProps,
});
}
export default injectIntl(connect(mapStateToProps, mapDispatchToProps, mergeProps)(ComposeForm));

View File

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import { changeComposeContentType } from '../../../actions/compose';
import MarkdownButton from '../components/markdown_button';
const mapStateToProps = (state, { intl }) => {
return {
active: state.getIn(['compose', 'content_type']) === 'text/markdown',
};
};
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch((_, getState) => {
const active = getState().getIn(['compose', 'content_type']) === 'text/markdown';
dispatch(changeComposeContentType(active ? 'text/plain' : 'text/markdown'));
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(MarkdownButton);

View File

@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { addPoll, removePoll } from '../../../actions/compose';
import PollButton from '../components/poll_button';
const mapStateToProps = state => ({
unavailable: state.getIn(['compose', 'is_uploading']),
active: state.getIn(['compose', 'poll']) !== null,
});
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch((_, getState) => {
if (getState().getIn(['compose', 'poll'])) {
dispatch(removePoll());
} else {
dispatch(addPoll());
}
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PollButton);

View File

@ -1,28 +0,0 @@
import { connect } from 'react-redux';
import { changeComposeVisibility } from '../../../actions/compose';
import { openModal, closeModal } from '../../../actions/modals';
import { isUserTouching } from '../../../is_mobile';
import PrivacyDropdown from '../components/privacy_dropdown';
const mapStateToProps = state => ({
isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'ACTIONS'),
value: state.getIn(['compose', 'privacy']),
unavailable: !!state.getIn(['compose', 'id']),
});
const mapDispatchToProps = dispatch => ({
onChange(value) {
dispatch(changeComposeVisibility(value));
},
isUserTouching,
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
onModalClose: () => {
dispatch(closeModal('ACTIONS'));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);

View File

@ -7,10 +7,14 @@ import { makeGetStatus } from 'soapbox/selectors';
const getStatus = makeGetStatus();
interface IQuotedStatusContainer {
composeId: string,
}
/** QuotedStatus shown in post composer. */
const QuotedStatusContainer: React.FC = () => {
const QuotedStatusContainer: React.FC<IQuotedStatusContainer> = ({ composeId }) => {
const dispatch = useAppDispatch();
const status = useAppSelector(state => getStatus(state, { id: state.compose.quote! }));
const status = useAppSelector(state => getStatus(state, { id: state.compose.get(composeId)!.quote! }));
const onCancel = () => {
dispatch(cancelQuoteCompose());

View File

@ -1,31 +0,0 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
import ReplyIndicator from '../components/reply_indicator';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = state => {
const statusId = state.getIn(['compose', 'in_reply_to']);
const editing = !!state.getIn(['compose', 'id']);
return {
status: getStatus(state, { id: statusId }),
hideActions: editing,
};
};
return mapStateToProps;
};
const mapDispatchToProps = dispatch => ({
onCancel() {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

View File

@ -0,0 +1,35 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from 'soapbox/actions/compose';
import { makeGetStatus } from 'soapbox/selectors';
import ReplyIndicator from '../components/reply_indicator';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Status } from 'soapbox/types/entities';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state: RootState, { composeId }: { composeId: string }) => {
const statusId = state.compose.get(composeId)!.in_reply_to!;
const editing = !!state.compose.get(composeId)!.id;
return {
status: getStatus(state, { id: statusId }) as Status,
hideActions: editing,
};
};
return mapStateToProps;
};
const mapDispatchToProps = (dispatch: AppDispatch) => ({
onCancel() {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

View File

@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { addSchedule, removeSchedule } from '../../../actions/compose';
import ScheduleButton from '../components/schedule_button';
const mapStateToProps = state => ({
active: state.getIn(['compose', 'schedule']) ? true : false,
unavailable: !!state.getIn(['compose', 'id']),
});
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch((dispatch, getState) => {
if (getState().getIn(['compose', 'schedule'])) {
dispatch(removeSchedule());
} else {
dispatch(addSchedule());
}
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleButton);

View File

@ -1,16 +0,0 @@
import React from 'react';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { ScheduleForm } from 'soapbox/features/ui/util/async-components';
export default class ScheduleFormContainer extends React.PureComponent {
render() {
return (
<BundleContainer fetchComponent={ScheduleForm}>
{Component => <Component {...this.props} />}
</BundleContainer>
);
}
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { ScheduleForm } from 'soapbox/features/ui/util/async-components';
import type { IScheduleForm } from '../components/schedule_form';
const ScheduleFormContainer: React.FC<IScheduleForm> = (props) => (
<BundleContainer fetchComponent={ScheduleForm}>
{Component => <Component {...props} />}
</BundleContainer>
);
export default ScheduleFormContainer;

View File

@ -1,18 +0,0 @@
import { connect } from 'react-redux';
import { changeComposeSpoilerness } from '../../../actions/compose';
import SpoilerButton from '../components/spoiler_button';
const mapStateToProps = (state, { intl }) => ({
active: state.getIn(['compose', 'spoiler']),
});
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch(changeComposeSpoilerness());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(SpoilerButton);

View File

@ -1,20 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { uploadCompose } from '../../../actions/compose';
import UploadButton from '../components/upload_button';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
});
const mapDispatchToProps = (dispatch, { intl }) => ({
onSelectFile(files) {
dispatch(uploadCompose(files, intl));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(UploadButton));

View File

@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import { uploadCompose } from 'soapbox/actions/compose';
import UploadButton from '../components/upload_button';
import type { IntlShape } from 'react-intl';
import type { AppDispatch, RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState, { composeId }: { composeId: string }) => ({
disabled: state.compose.get(composeId)!.is_uploading,
resetFileKey: state.compose.get(composeId)!.resetFileKey,
});
const mapDispatchToProps = (dispatch: AppDispatch, { composeId }: { composeId: string }) => ({
onSelectFile(files: FileList, intl: IntlShape) {
dispatch(uploadCompose(composeId, files, intl));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);

View File

@ -1,23 +1,24 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { useAppSelector } from 'soapbox/hooks';
import Warning from '../components/warning';
const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
const mapStateToProps = state => {
const me = state.get('me');
return {
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
};
};
interface IWarningWrapper {
composeId: string,
}
const WarningWrapper: React.FC<IWarningWrapper> = ({ composeId }) => {
const me = useAppSelector((state) => state.me);
const needsLockWarning = useAppSelector(state => state.compose.get(composeId)!.privacy === 'private' && !state.accounts.get(me)!.locked);
const hashtagWarning = useAppSelector(state => state.compose.get(composeId)!.privacy !== 'public' && APPROX_HASHTAG_RE.test(state.compose.get(composeId)!.text));
const directMessageWarning = useAppSelector(state => state.compose.get(composeId)!.privacy === 'direct');
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <Link to='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></Link> }} />} />;
}
@ -40,10 +41,4 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
return null;
};
WarningWrapper.propTypes = {
needsLockWarning: PropTypes.bool,
hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool,
};
export default connect(mapStateToProps)(WarningWrapper);
export default WarningWrapper;

View File

@ -7,7 +7,7 @@ import { Link } from 'react-router-dom';
import { Column, Spinner } from 'soapbox/components/ui';
import ComposeFormContainer from '../../../../soapbox/features/compose/containers/compose_form_container';
import ComposeForm from '../../../../soapbox/features/compose/components/compose-form';
import { connectGroupStream } from '../../../actions/streaming';
import { expandGroupTimeline } from '../../../actions/timelines';
import Avatar from '../../../components/avatar';
@ -85,7 +85,7 @@ class GroupTimeline extends React.PureComponent {
<Link className='timeline-compose-block__avatar' to={`/@${acct}`}>
<Avatar account={account} size={46} />
</Link>
<ComposeFormContainer group={group} shouldCondense autoFocus={false} />
<ComposeForm group={group} shouldCondense autoFocus={false} />
</div>
)}

View File

@ -17,16 +17,17 @@ const messages = defineMessages({
const getAccount = makeGetAccount();
interface IAccount {
composeId: string,
accountId: string,
author: boolean,
}
const Account: React.FC<IAccount> = ({ accountId, author }) => {
const Account: React.FC<IAccount> = ({ composeId, accountId, author }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => getAccount(state, accountId));
const added = useAppSelector((state) => !!account && state.compose.to?.includes(account.acct));
const added = useAppSelector((state) => !!account && state.compose.get(composeId)!.to?.includes(account.acct));
const onRemove = () => dispatch(removeFromMentions(accountId));
const onAdd = () => dispatch(addToMentions(accountId));

View File

@ -135,7 +135,6 @@ const Thread: React.FC<IThread> = (props) => {
const me = useAppSelector(state => state.me);
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
const displayMedia = settings.get('displayMedia') as DisplayMedia;
const askReplyConfirmation = useAppSelector(state => state.compose.text.trim().length !== 0);
const { ancestorsIds, descendantsIds } = useAppSelector(state => {
let ancestorsIds = ImmutableOrderedSet<string>();
@ -201,15 +200,7 @@ const Thread: React.FC<IThread> = (props) => {
};
const handleReplyClick = (status: StatusEntity) => {
if (askReplyConfirmation) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(status)),
}));
} else {
dispatch(replyCompose(status));
}
dispatch(replyCompose(status));
};
const handleModalReblog = (status: StatusEntity) => {

View File

@ -7,7 +7,7 @@ import { checkComposeContent } from 'soapbox/components/modal_root';
import { Modal } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import ComposeFormContainer from '../../compose/containers/compose_form_container';
import ComposeForm from '../../compose/components/compose-form';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -23,11 +23,11 @@ const ComposeModal: React.FC<IComposeModal> = ({ onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const statusId = useAppSelector((state) => state.compose.id);
const hasComposeContent = useAppSelector((state) => checkComposeContent(state.compose));
const privacy = useAppSelector((state) => state.compose.privacy);
const inReplyTo = useAppSelector((state) => state.compose.in_reply_to);
const quote = useAppSelector((state) => state.compose.quote);
const compose = useAppSelector((state) => state.compose.get('compose-modal'));
const { id: statusId, privacy, in_reply_to: inReplyTo, quote } = compose!;
const hasComposeContent = checkComposeContent(compose);
const onClickClose = () => {
if (hasComposeContent) {
@ -69,7 +69,7 @@ const ComposeModal: React.FC<IComposeModal> = ({ onClose }) => {
title={renderTitle()}
onClose={onClickClose}
>
<ComposeFormContainer />
<ComposeForm id='compose-modal' />
</Modal>
);
};

View File

@ -11,11 +11,12 @@ import Account from '../../reply_mentions/account';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
interface IReplyMentionsModal {
composeId: string,
onClose: (string: string) => void,
}
const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ onClose }) => {
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.in_reply_to! }));
const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ composeId, onClose }) => {
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.get(composeId)?.in_reply_to! }));
const account = useAppSelector((state) => state.accounts.get(state.me));
const mentions = statusToMentionsAccountIdsArray(status!, account!);
@ -33,7 +34,7 @@ const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ onClose }) => {
closePosition='left'
>
<div className='reply-mentions-modal__accounts'>
{mentions.map(accountId => <Account key={accountId} accountId={accountId} author={author === accountId} />)}
{mentions.map(accountId => <Account composeId={composeId} key={accountId} accountId={accountId} author={author === accountId} />)}
</div>
</Modal>
);

View File

@ -387,7 +387,7 @@ const UI: React.FC = ({ children }) => {
dragTargets.current = [];
if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
dispatch(uploadCompose(e.dataTransfer.files, intl));
dispatch(uploadCompose('home', e.dataTransfer.files, intl));
}
};

View File

@ -18,7 +18,7 @@ import { useAppSelector, useOwnAccount, useFeatures, useSoapboxConfig } from 'so
import Avatar from '../components/avatar';
import { Card, CardBody, Layout } from '../components/ui';
import ComposeFormContainer from '../features/compose/containers/compose_form_container';
import ComposeForm from '../features/compose/components/compose-form';
import BundleContainer from '../features/ui/containers/bundle_container';
// import GroupSidebarPanel from '../features/groups/sidebar_panel';
@ -47,8 +47,8 @@ const HomePage: React.FC = ({ children }) => {
<Avatar account={account} size={46} />
</Link>
<ComposeFormContainer
// @ts-ignore
<ComposeForm
id='home'
shouldCondense
autoFocus={false}
clickableAreaRef={composeBlock}

View File

@ -1,499 +1,499 @@
import { List as ImmutableList, Record as ImmutableRecord, fromJS } from 'immutable';
// import { List as ImmutableList, Record as ImmutableRecord, fromJS } from 'immutable';
import * as actions from 'soapbox/actions/compose';
import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from 'soapbox/actions/me';
import { SETTING_CHANGE } from 'soapbox/actions/settings';
import { TIMELINE_DELETE } from 'soapbox/actions/timelines';
import { TagRecord } from 'soapbox/normalizers';
import { normalizeStatus } from 'soapbox/normalizers/status';
// import * as actions from 'soapbox/actions/compose';
// import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from 'soapbox/actions/me';
// import { SETTING_CHANGE } from 'soapbox/actions/settings';
// import { TIMELINE_DELETE } from 'soapbox/actions/timelines';
// import { TagRecord } from 'soapbox/normalizers';
// import { normalizeStatus } from 'soapbox/normalizers/status';
import reducer, { ReducerRecord } from '../compose';
// import reducer, { ReducerRecord } from '../compose';
describe('compose reducer', () => {
it('returns the initial state by default', () => {
const state = reducer(undefined, {} as any);
expect(state.toJS()).toMatchObject({
mounted: 0,
sensitive: false,
spoiler: false,
spoiler_text: '',
privacy: 'public',
text: '',
focusDate: null,
caretPosition: null,
in_reply_to: null,
is_composing: false,
is_submitting: false,
is_changing_upload: false,
is_uploading: false,
progress: 0,
media_attachments: [],
poll: null,
suggestion_token: null,
suggestions: [],
default_privacy: 'public',
default_sensitive: false,
tagHistory: [],
content_type: 'text/plain',
});
expect(state.get('idempotencyKey').length === 36);
});
// describe('compose reducer', () => {
// it('returns the initial state by default', () => {
// const state = reducer(undefined, {} as any);
// expect(state.toJS()).toMatchObject({
// mounted: 0,
// sensitive: false,
// spoiler: false,
// spoiler_text: '',
// privacy: 'public',
// text: '',
// focusDate: null,
// caretPosition: null,
// in_reply_to: null,
// is_composing: false,
// is_submitting: false,
// is_changing_upload: false,
// is_uploading: false,
// progress: 0,
// media_attachments: [],
// poll: null,
// suggestion_token: null,
// suggestions: [],
// default_privacy: 'public',
// default_sensitive: false,
// tagHistory: [],
// content_type: 'text/plain',
// });
// expect(state.get('idempotencyKey').length === 36);
// });
describe('COMPOSE_SET_STATUS', () => {
it('strips Pleroma integer attachments', () => {
const action = {
type: actions.COMPOSE_SET_STATUS,
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
v: { software: 'Pleroma' },
withRedraft: true,
};
// describe('COMPOSE_SET_STATUS', () => {
// it('strips Pleroma integer attachments', () => {
// const action = {
// type: actions.COMPOSE_SET_STATUS,
// status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
// v: { software: 'Pleroma' },
// withRedraft: true,
// };
const result = reducer(undefined, action);
expect(result.get('media_attachments').isEmpty()).toBe(true);
});
// const result = reducer(undefined, action);
// expect(result.get('media_attachments').isEmpty()).toBe(true);
// });
it('leaves non-Pleroma integer attachments alone', () => {
const action = {
type: actions.COMPOSE_SET_STATUS,
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
};
// it('leaves non-Pleroma integer attachments alone', () => {
// const action = {
// type: actions.COMPOSE_SET_STATUS,
// status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
// };
const result = reducer(undefined, action);
expect(result.getIn(['media_attachments', 0, 'id'])).toEqual('508107650');
});
// const result = reducer(undefined, action);
// expect(result.getIn(['media_attachments', 0, 'id'])).toEqual('508107650');
// });
it('sets the id when editing a post', () => {
const action = {
withRedraft: false,
type: actions.COMPOSE_SET_STATUS,
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
};
// it('sets the id when editing a post', () => {
// const action = {
// withRedraft: false,
// type: actions.COMPOSE_SET_STATUS,
// status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
// };
const result = reducer(undefined, action);
expect(result.get('id')).toEqual('AHU2RrX0wdcwzCYjFQ');
});
// const result = reducer(undefined, action);
// expect(result.get('id')).toEqual('AHU2RrX0wdcwzCYjFQ');
// });
it('does not set the id when redrafting a post', () => {
const action = {
withRedraft: true,
type: actions.COMPOSE_SET_STATUS,
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
};
// it('does not set the id when redrafting a post', () => {
// const action = {
// withRedraft: true,
// type: actions.COMPOSE_SET_STATUS,
// status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
// };
const result = reducer(undefined, action);
expect(result.get('id')).toEqual(null);
});
});
// const result = reducer(undefined, action);
// expect(result.get('id')).toEqual(null);
// });
// });
it('uses \'public\' scope as default', () => {
const action = {
type: actions.COMPOSE_REPLY,
status: ImmutableRecord({})(),
account: ImmutableRecord({})(),
};
expect(reducer(undefined, action).toJS()).toMatchObject({ privacy: 'public' });
});
// it('uses \'public\' scope as default', () => {
// const action = {
// type: actions.COMPOSE_REPLY,
// status: ImmutableRecord({})(),
// account: ImmutableRecord({})(),
// };
// expect(reducer(undefined, action).toJS()).toMatchObject({ privacy: 'public' });
// });
it('uses \'direct\' scope when replying to a DM', () => {
const state = ReducerRecord({ default_privacy: 'public' });
const action = {
type: actions.COMPOSE_REPLY,
status: ImmutableRecord({ visibility: 'direct' })(),
account: ImmutableRecord({})(),
};
expect(reducer(state as any, action).toJS()).toMatchObject({ privacy: 'direct' });
});
// it('uses \'direct\' scope when replying to a DM', () => {
// const state = ReducerRecord({ default_privacy: 'public' });
// const action = {
// type: actions.COMPOSE_REPLY,
// status: ImmutableRecord({ visibility: 'direct' })(),
// account: ImmutableRecord({})(),
// };
// expect(reducer(state as any, action).toJS()).toMatchObject({ privacy: 'direct' });
// });
it('uses \'private\' scope when replying to a private post', () => {
const state = ReducerRecord({ default_privacy: 'public' });
const action = {
type: actions.COMPOSE_REPLY,
status: ImmutableRecord({ visibility: 'private' })(),
account: ImmutableRecord({})(),
};
expect(reducer(state as any, action).toJS()).toMatchObject({ privacy: 'private' });
});
// it('uses \'private\' scope when replying to a private post', () => {
// const state = ReducerRecord({ default_privacy: 'public' });
// const action = {
// type: actions.COMPOSE_REPLY,
// status: ImmutableRecord({ visibility: 'private' })(),
// account: ImmutableRecord({})(),
// };
// expect(reducer(state as any, action).toJS()).toMatchObject({ privacy: 'private' });
// });
it('uses \'unlisted\' scope when replying to an unlisted post', () => {
const state = ReducerRecord({ default_privacy: 'public' });
const action = {
type: actions.COMPOSE_REPLY,
status: ImmutableRecord({ visibility: 'unlisted' })(),
account: ImmutableRecord({})(),
};
expect(reducer(state, action).toJS()).toMatchObject({ privacy: 'unlisted' });
});
// it('uses \'unlisted\' scope when replying to an unlisted post', () => {
// const state = ReducerRecord({ default_privacy: 'public' });
// const action = {
// type: actions.COMPOSE_REPLY,
// status: ImmutableRecord({ visibility: 'unlisted' })(),
// account: ImmutableRecord({})(),
// };
// expect(reducer(state, action).toJS()).toMatchObject({ privacy: 'unlisted' });
// });
it('uses \'private\' scope when set as preference and replying to a public post', () => {
const state = ReducerRecord({ default_privacy: 'private' });
const action = {
type: actions.COMPOSE_REPLY,
status: ImmutableRecord({ visibility: 'public' })(),
account: ImmutableRecord({})(),
};
expect(reducer(state, action).toJS()).toMatchObject({ privacy: 'private' });
});
// it('uses \'private\' scope when set as preference and replying to a public post', () => {
// const state = ReducerRecord({ default_privacy: 'private' });
// const action = {
// type: actions.COMPOSE_REPLY,
// status: ImmutableRecord({ visibility: 'public' })(),
// account: ImmutableRecord({})(),
// };
// expect(reducer(state, action).toJS()).toMatchObject({ privacy: 'private' });
// });
it('uses \'unlisted\' scope when set as preference and replying to a public post', () => {
const state = ReducerRecord({ default_privacy: 'unlisted' });
const action = {
type: actions.COMPOSE_REPLY,
status: ImmutableRecord({ visibility: 'public' })(),
account: ImmutableRecord({})(),
};
expect(reducer(state, action).toJS()).toMatchObject({ privacy: 'unlisted' });
});
// it('uses \'unlisted\' scope when set as preference and replying to a public post', () => {
// const state = ReducerRecord({ default_privacy: 'unlisted' });
// const action = {
// type: actions.COMPOSE_REPLY,
// status: ImmutableRecord({ visibility: 'public' })(),
// account: ImmutableRecord({})(),
// };
// expect(reducer(state, action).toJS()).toMatchObject({ privacy: 'unlisted' });
// });
it('sets preferred scope on user login', () => {
const state = ReducerRecord({ default_privacy: 'public' });
const action = {
type: ME_FETCH_SUCCESS,
me: { pleroma: { settings_store: { soapbox_fe: { defaultPrivacy: 'unlisted' } } } },
};
expect(reducer(state, action).toJS()).toMatchObject({
default_privacy: 'unlisted',
privacy: 'unlisted',
});
});
// it('sets preferred scope on user login', () => {
// const state = ReducerRecord({ default_privacy: 'public' });
// const action = {
// type: ME_FETCH_SUCCESS,
// me: { pleroma: { settings_store: { soapbox_fe: { defaultPrivacy: 'unlisted' } } } },
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// default_privacy: 'unlisted',
// privacy: 'unlisted',
// });
// });
it('sets preferred scope on settings change', () => {
const state = ReducerRecord({ default_privacy: 'public' });
const action = {
type: SETTING_CHANGE,
path: ['defaultPrivacy'],
value: 'unlisted',
};
expect(reducer(state, action).toJS()).toMatchObject({
default_privacy: 'unlisted',
privacy: 'unlisted',
});
});
// it('sets preferred scope on settings change', () => {
// const state = ReducerRecord({ default_privacy: 'public' });
// const action = {
// type: SETTING_CHANGE,
// path: ['defaultPrivacy'],
// value: 'unlisted',
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// default_privacy: 'unlisted',
// privacy: 'unlisted',
// });
// });
it('sets default scope on settings save (but retains current scope)', () => {
const state = ReducerRecord({ default_privacy: 'public', privacy: 'public' });
const action = {
type: ME_PATCH_SUCCESS,
me: { pleroma: { settings_store: { soapbox_fe: { defaultPrivacy: 'unlisted' } } } },
};
expect(reducer(state, action).toJS()).toMatchObject({
default_privacy: 'unlisted',
privacy: 'public',
});
});
// it('sets default scope on settings save (but retains current scope)', () => {
// const state = ReducerRecord({ default_privacy: 'public', privacy: 'public' });
// const action = {
// type: ME_PATCH_SUCCESS,
// me: { pleroma: { settings_store: { soapbox_fe: { defaultPrivacy: 'unlisted' } } } },
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// default_privacy: 'unlisted',
// privacy: 'public',
// });
// });
it('should handle COMPOSE_MOUNT', () => {
const state = ReducerRecord({ mounted: 1 });
const action = {
type: actions.COMPOSE_MOUNT,
};
expect(reducer(state, action).toJS()).toMatchObject({
mounted: 2,
});
});
// it('should handle COMPOSE_MOUNT', () => {
// const state = ReducerRecord({ mounted: 1 });
// const action = {
// type: actions.COMPOSE_MOUNT,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// mounted: 2,
// });
// });
it('should handle COMPOSE_UNMOUNT', () => {
const state = ReducerRecord({ mounted: 1 });
const action = {
type: actions.COMPOSE_UNMOUNT,
};
expect(reducer(state, action).toJS()).toMatchObject({
mounted: 0,
});
});
// it('should handle COMPOSE_UNMOUNT', () => {
// const state = ReducerRecord({ mounted: 1 });
// const action = {
// type: actions.COMPOSE_UNMOUNT,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// mounted: 0,
// });
// });
it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, don\'t toggle if spoiler active', () => {
const state = ReducerRecord({ spoiler: true, sensitive: true, idempotencyKey: '' });
const action = {
type: actions.COMPOSE_SENSITIVITY_CHANGE,
};
expect(reducer(state, action).toJS()).toMatchObject({
sensitive: true,
});
});
// it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, don\'t toggle if spoiler active', () => {
// const state = ReducerRecord({ spoiler: true, sensitive: true, idempotencyKey: '' });
// const action = {
// type: actions.COMPOSE_SENSITIVITY_CHANGE,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// sensitive: true,
// });
// });
it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, toggle if spoiler inactive', () => {
const state = ReducerRecord({ spoiler: false, sensitive: true });
const action = {
type: actions.COMPOSE_SENSITIVITY_CHANGE,
};
expect(reducer(state, action).toJS()).toMatchObject({
sensitive: false,
});
});
// it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, toggle if spoiler inactive', () => {
// const state = ReducerRecord({ spoiler: false, sensitive: true });
// const action = {
// type: actions.COMPOSE_SENSITIVITY_CHANGE,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// sensitive: false,
// });
// });
it('should handle COMPOSE_SPOILERNESS_CHANGE on CW button click', () => {
const state = ReducerRecord({ spoiler_text: 'spoiler text', spoiler: true, media_attachments: ImmutableList() });
const action = {
type: actions.COMPOSE_SPOILERNESS_CHANGE,
};
expect(reducer(state, action).toJS()).toMatchObject({
spoiler: false,
spoiler_text: '',
});
});
// it('should handle COMPOSE_SPOILERNESS_CHANGE on CW button click', () => {
// const state = ReducerRecord({ spoiler_text: 'spoiler text', spoiler: true, media_attachments: ImmutableList() });
// const action = {
// type: actions.COMPOSE_SPOILERNESS_CHANGE,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// spoiler: false,
// spoiler_text: '',
// });
// });
it('should handle COMPOSE_SPOILER_TEXT_CHANGE', () => {
const state = ReducerRecord({ spoiler_text: 'prevtext' });
const action = {
type: actions.COMPOSE_SPOILER_TEXT_CHANGE,
text: 'nexttext',
};
expect(reducer(state, action).toJS()).toMatchObject({
spoiler_text: 'nexttext',
});
});
// it('should handle COMPOSE_SPOILER_TEXT_CHANGE', () => {
// const state = ReducerRecord({ spoiler_text: 'prevtext' });
// const action = {
// type: actions.COMPOSE_SPOILER_TEXT_CHANGE,
// text: 'nexttext',
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// spoiler_text: 'nexttext',
// });
// });
it('should handle COMPOSE_VISIBILITY_CHANGE', () => {
const state = ReducerRecord({ privacy: 'public' });
const action = {
type: actions.COMPOSE_VISIBILITY_CHANGE,
value: 'direct',
};
expect(reducer(state, action).toJS()).toMatchObject({
privacy: 'direct',
});
});
// it('should handle COMPOSE_VISIBILITY_CHANGE', () => {
// const state = ReducerRecord({ privacy: 'public' });
// const action = {
// type: actions.COMPOSE_VISIBILITY_CHANGE,
// value: 'direct',
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// privacy: 'direct',
// });
// });
describe('COMPOSE_CHANGE', () => {
it('should handle text changing', () => {
const state = ReducerRecord({ text: 'prevtext' });
const action = {
type: actions.COMPOSE_CHANGE,
text: 'nexttext',
};
expect(reducer(state, action).toJS()).toMatchObject({
text: 'nexttext',
});
});
});
// describe('COMPOSE_CHANGE', () => {
// it('should handle text changing', () => {
// const state = ReducerRecord({ text: 'prevtext' });
// const action = {
// type: actions.COMPOSE_CHANGE,
// text: 'nexttext',
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// text: 'nexttext',
// });
// });
// });
it('should handle COMPOSE_COMPOSING_CHANGE', () => {
const state = ReducerRecord({ is_composing: true });
const action = {
type: actions.COMPOSE_COMPOSING_CHANGE,
value: false,
};
expect(reducer(state, action).toJS()).toMatchObject({
is_composing: false,
});
});
// it('should handle COMPOSE_COMPOSING_CHANGE', () => {
// const state = ReducerRecord({ is_composing: true });
// const action = {
// type: actions.COMPOSE_COMPOSING_CHANGE,
// value: false,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// is_composing: false,
// });
// });
it('should handle COMPOSE_SUBMIT_REQUEST', () => {
const state = ReducerRecord({ is_submitting: false });
const action = {
type: actions.COMPOSE_SUBMIT_REQUEST,
};
expect(reducer(state, action).toJS()).toMatchObject({
is_submitting: true,
});
});
// it('should handle COMPOSE_SUBMIT_REQUEST', () => {
// const state = ReducerRecord({ is_submitting: false });
// const action = {
// type: actions.COMPOSE_SUBMIT_REQUEST,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// is_submitting: true,
// });
// });
it('should handle COMPOSE_UPLOAD_CHANGE_REQUEST', () => {
const state = ReducerRecord({ is_changing_upload: false });
const action = {
type: actions.COMPOSE_UPLOAD_CHANGE_REQUEST,
};
expect(reducer(state, action).toJS()).toMatchObject({
is_changing_upload: true,
});
});
// it('should handle COMPOSE_UPLOAD_CHANGE_REQUEST', () => {
// const state = ReducerRecord({ is_changing_upload: false });
// const action = {
// type: actions.COMPOSE_UPLOAD_CHANGE_REQUEST,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// is_changing_upload: true,
// });
// });
it('should handle COMPOSE_SUBMIT_SUCCESS', () => {
const state = ReducerRecord({ default_privacy: 'public', privacy: 'private' });
const action = {
type: actions.COMPOSE_SUBMIT_SUCCESS,
};
expect(reducer(state, action).toJS()).toMatchObject({
privacy: 'public',
});
});
// it('should handle COMPOSE_SUBMIT_SUCCESS', () => {
// const state = ReducerRecord({ default_privacy: 'public', privacy: 'private' });
// const action = {
// type: actions.COMPOSE_SUBMIT_SUCCESS,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// privacy: 'public',
// });
// });
it('should handle COMPOSE_SUBMIT_FAIL', () => {
const state = ReducerRecord({ is_submitting: true });
const action = {
type: actions.COMPOSE_SUBMIT_FAIL,
};
expect(reducer(state, action).toJS()).toMatchObject({
is_submitting: false,
});
});
// it('should handle COMPOSE_SUBMIT_FAIL', () => {
// const state = ReducerRecord({ is_submitting: true });
// const action = {
// type: actions.COMPOSE_SUBMIT_FAIL,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// is_submitting: false,
// });
// });
it('should handle COMPOSE_UPLOAD_CHANGE_FAIL', () => {
const state = ReducerRecord({ is_changing_upload: true });
const action = {
type: actions.COMPOSE_UPLOAD_CHANGE_FAIL,
};
expect(reducer(state, action).toJS()).toMatchObject({
is_changing_upload: false,
});
});
// it('should handle COMPOSE_UPLOAD_CHANGE_FAIL', () => {
// const state = ReducerRecord({ is_changing_upload: true });
// const action = {
// type: actions.COMPOSE_UPLOAD_CHANGE_FAIL,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// is_changing_upload: false,
// });
// });
it('should handle COMPOSE_UPLOAD_REQUEST', () => {
const state = ReducerRecord({ is_uploading: false });
const action = {
type: actions.COMPOSE_UPLOAD_REQUEST,
};
expect(reducer(state, action).toJS()).toMatchObject({
is_uploading: true,
});
});
// it('should handle COMPOSE_UPLOAD_REQUEST', () => {
// const state = ReducerRecord({ is_uploading: false });
// const action = {
// type: actions.COMPOSE_UPLOAD_REQUEST,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// is_uploading: true,
// });
// });
it('should handle COMPOSE_UPLOAD_SUCCESS', () => {
const state = ReducerRecord({ media_attachments: ImmutableList() });
const media = [
{
description: null,
id: '1375732379',
pleroma: {
mime_type: 'image/jpeg',
},
preview_url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
remote_url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
text_url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
type: 'image',
url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
},
];
const action = {
type: actions.COMPOSE_UPLOAD_SUCCESS,
media: media,
skipLoading: true,
};
expect(reducer(state, action).toJS()).toMatchObject({
is_uploading: false,
});
});
// it('should handle COMPOSE_UPLOAD_SUCCESS', () => {
// const state = ReducerRecord({ media_attachments: ImmutableList() });
// const media = [
// {
// description: null,
// id: '1375732379',
// pleroma: {
// mime_type: 'image/jpeg',
// },
// preview_url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
// remote_url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
// text_url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
// type: 'image',
// url: 'https://media.gleasonator.com/media_attachments/files/000/853/856/original/7035d67937053e1d.jpg',
// },
// ];
// const action = {
// type: actions.COMPOSE_UPLOAD_SUCCESS,
// media: media,
// skipLoading: true,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// is_uploading: false,
// });
// });
it('should handle COMPOSE_UPLOAD_FAIL', () => {
const state = ReducerRecord({ is_uploading: true });
const action = {
type: actions.COMPOSE_UPLOAD_FAIL,
};
expect(reducer(state, action).toJS()).toMatchObject({
is_uploading: false,
});
});
// it('should handle COMPOSE_UPLOAD_FAIL', () => {
// const state = ReducerRecord({ is_uploading: true });
// const action = {
// type: actions.COMPOSE_UPLOAD_FAIL,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// is_uploading: false,
// });
// });
it('should handle COMPOSE_UPLOAD_PROGRESS', () => {
const state = ReducerRecord({ progress: 0 });
const action = {
type: actions.COMPOSE_UPLOAD_PROGRESS,
loaded: 10,
total: 15,
};
expect(reducer(state, action).toJS()).toMatchObject({
progress: 67,
});
});
// it('should handle COMPOSE_UPLOAD_PROGRESS', () => {
// const state = ReducerRecord({ progress: 0 });
// const action = {
// type: actions.COMPOSE_UPLOAD_PROGRESS,
// loaded: 10,
// total: 15,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// progress: 67,
// });
// });
it('should handle COMPOSE_SUGGESTIONS_CLEAR', () => {
const action = {
type: actions.COMPOSE_SUGGESTIONS_CLEAR,
suggestions: [],
suggestion_token: 'aiekdns3',
};
expect(reducer(undefined, action).toJS()).toMatchObject({
suggestion_token: null,
});
});
// it('should handle COMPOSE_SUGGESTIONS_CLEAR', () => {
// const action = {
// type: actions.COMPOSE_SUGGESTIONS_CLEAR,
// suggestions: [],
// suggestion_token: 'aiekdns3',
// };
// expect(reducer(undefined, action).toJS()).toMatchObject({
// suggestion_token: null,
// });
// });
it('should handle COMPOSE_SUGGESTION_TAGS_UPDATE', () => {
const state = ReducerRecord({ tagHistory: ImmutableList([ 'hashtag' ]) });
const action = {
type: actions.COMPOSE_SUGGESTION_TAGS_UPDATE,
token: 'aaadken3',
currentTrends: ImmutableList([
TagRecord({ name: 'hashtag' }),
]),
};
expect(reducer(state, action).toJS()).toMatchObject({
suggestion_token: 'aaadken3',
suggestions: [],
tagHistory: [ 'hashtag' ],
});
});
// it('should handle COMPOSE_SUGGESTION_TAGS_UPDATE', () => {
// const state = ReducerRecord({ tagHistory: ImmutableList([ 'hashtag' ]) });
// const action = {
// type: actions.COMPOSE_SUGGESTION_TAGS_UPDATE,
// token: 'aaadken3',
// currentTrends: ImmutableList([
// TagRecord({ name: 'hashtag' }),
// ]),
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// suggestion_token: 'aaadken3',
// suggestions: [],
// tagHistory: [ 'hashtag' ],
// });
// });
it('should handle COMPOSE_TAG_HISTORY_UPDATE', () => {
const action = {
type: actions.COMPOSE_TAG_HISTORY_UPDATE,
tags: [ 'hashtag', 'hashtag2'],
};
expect(reducer(undefined, action).toJS()).toMatchObject({
tagHistory: [ 'hashtag', 'hashtag2' ],
});
});
// it('should handle COMPOSE_TAG_HISTORY_UPDATE', () => {
// const action = {
// type: actions.COMPOSE_TAG_HISTORY_UPDATE,
// tags: [ 'hashtag', 'hashtag2'],
// };
// expect(reducer(undefined, action).toJS()).toMatchObject({
// tagHistory: [ 'hashtag', 'hashtag2' ],
// });
// });
it('should handle TIMELINE_DELETE - delete status from timeline', () => {
const state = ReducerRecord({ in_reply_to: '9wk6pmImMrZjgrK7iC' });
const action = {
type: TIMELINE_DELETE,
id: '9wk6pmImMrZjgrK7iC',
};
expect(reducer(state, action).toJS()).toMatchObject({
in_reply_to: null,
});
});
// it('should handle TIMELINE_DELETE - delete status from timeline', () => {
// const state = ReducerRecord({ in_reply_to: '9wk6pmImMrZjgrK7iC' });
// const action = {
// type: TIMELINE_DELETE,
// id: '9wk6pmImMrZjgrK7iC',
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// in_reply_to: null,
// });
// });
it('should handle COMPOSE_POLL_ADD', () => {
const state = ReducerRecord({ poll: null });
const initialPoll = Object({
options: [
'',
'',
],
expires_in: 86400,
multiple: false,
});
const action = {
type: actions.COMPOSE_POLL_ADD,
};
expect(reducer(state, action).toJS()).toMatchObject({
poll: initialPoll,
});
});
// it('should handle COMPOSE_POLL_ADD', () => {
// const state = ReducerRecord({ poll: null });
// const initialPoll = Object({
// options: [
// '',
// '',
// ],
// expires_in: 86400,
// multiple: false,
// });
// const action = {
// type: actions.COMPOSE_POLL_ADD,
// };
// expect(reducer(state, action).toJS()).toMatchObject({
// poll: initialPoll,
// });
// });
it('should handle COMPOSE_POLL_REMOVE', () => {
const action = {
type: actions.COMPOSE_POLL_REMOVE,
};
expect(reducer(undefined, action).toJS()).toMatchObject({
poll: null,
});
});
// it('should handle COMPOSE_POLL_REMOVE', () => {
// const action = {
// type: actions.COMPOSE_POLL_REMOVE,
// };
// expect(reducer(undefined, action).toJS()).toMatchObject({
// poll: null,
// });
// });
it('should handle COMPOSE_POLL_OPTION_CHANGE', () => {
const initialPoll = Object({
options: [
'option 1',
'option 2',
],
expires_in: 86400,
multiple: false,
});
const state = ReducerRecord({ poll: initialPoll });
const action = {
type: actions.COMPOSE_POLL_OPTION_CHANGE,
index: 0,
title: 'change option',
};
const updatedPoll = Object({
options: [
'change option',
'option 2',
],
expires_in: 86400,
multiple: false,
});
expect(reducer(state, action).toJS()).toMatchObject({
poll: updatedPoll,
});
});
// it('should handle COMPOSE_POLL_OPTION_CHANGE', () => {
// const initialPoll = Object({
// options: [
// 'option 1',
// 'option 2',
// ],
// expires_in: 86400,
// multiple: false,
// });
// const state = ReducerRecord({ poll: initialPoll });
// const action = {
// type: actions.COMPOSE_POLL_OPTION_CHANGE,
// index: 0,
// title: 'change option',
// };
// const updatedPoll = Object({
// options: [
// 'change option',
// 'option 2',
// ],
// expires_in: 86400,
// multiple: false,
// });
// expect(reducer(state, action).toJS()).toMatchObject({
// poll: updatedPoll,
// });
// });
it('sets the post content-type', () => {
const action = {
type: actions.COMPOSE_TYPE_CHANGE,
value: 'text/plain',
};
expect(reducer(undefined, action).toJS()).toMatchObject({ content_type: 'text/plain' });
});
});
// it('sets the post content-type', () => {
// const action = {
// type: actions.COMPOSE_TYPE_CHANGE,
// value: 'text/plain',
// };
// expect(reducer(undefined, action).toJS()).toMatchObject({ content_type: 'text/plain' });
// });
// });

View File

@ -6,8 +6,6 @@ import { PLEROMA } from 'soapbox/utils/features';
import { hasIntegerMediaIds } from 'soapbox/utils/status';
import {
COMPOSE_MOUNT,
COMPOSE_UNMOUNT,
COMPOSE_CHANGE,
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
@ -68,7 +66,7 @@ import type {
Tag,
} from 'soapbox/types/entities';
const getResetFileKey = () => Math.floor((Math.random() * 0x10000));
// const getResetFileKey = () => Math.floor((Math.random() * 0x10000));
const PollRecord = ImmutableRecord({
options: ImmutableList(['', '']),
@ -76,7 +74,7 @@ const PollRecord = ImmutableRecord({
multiple: false,
});
export const ReducerRecord = ImmutableRecord({
export const ReducerCompose = ImmutableRecord({
caretPosition: null as number | null,
content_type: 'text/plain',
default_content_type: 'text/plain',
@ -91,7 +89,6 @@ export const ReducerRecord = ImmutableRecord({
is_submitting: false,
is_uploading: false,
media_attachments: ImmutableList<AttachmentEntity>(),
mounted: 0,
poll: null as Poll | null,
privacy: 'public',
progress: 0,
@ -101,17 +98,18 @@ export const ReducerRecord = ImmutableRecord({
sensitive: false,
spoiler: false,
spoiler_text: '',
suggestions: ImmutableList(),
suggestions: ImmutableList<string>(),
suggestion_token: null as string | null,
tagHistory: ImmutableList<string>(),
text: '',
to: ImmutableOrderedSet<string>(),
});
type State = ReturnType<typeof ReducerRecord>;
type State = ImmutableMap<string, Compose>;
type Compose = ReturnType<typeof ReducerCompose>;
type Poll = ReturnType<typeof PollRecord>;
const statusToTextMentions = (state: State, status: ImmutableMap<string, any>, account: AccountEntity) => {
const statusToTextMentions = (status: ImmutableMap<string, any>, account: AccountEntity) => {
const author = status.getIn(['account', 'acct']);
const mentions = status.get('mentions')?.map((m: ImmutableMap<string, any>) => m.get('acct')) || [];
@ -140,33 +138,33 @@ export const statusToMentionsAccountIdsArray = (status: StatusEntity, account: A
.delete(account.id) as ImmutableOrderedSet<string>;
};
function clearAll(state: State) {
return ReducerRecord({
content_type: state.default_content_type,
privacy: state.default_privacy,
function clearAll(compose: Compose) {
return ReducerCompose({
content_type: compose.default_content_type,
privacy: compose.default_privacy,
idempotencyKey: uuid(),
});
}
function appendMedia(state: State, media: APIEntity) {
const prevSize = state.media_attachments.size;
function appendMedia(compose: Compose, media: APIEntity) {
const prevSize = compose.media_attachments.size;
return state.withMutations(map => {
return compose.withMutations(map => {
map.update('media_attachments', list => list.push(normalizeAttachment(media)));
map.set('is_uploading', false);
map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
map.set('idempotencyKey', uuid());
if (prevSize === 0 && (state.default_sensitive || state.spoiler)) {
if (prevSize === 0 && (compose.default_sensitive || compose.spoiler)) {
map.set('sensitive', true);
}
});
}
function removeMedia(state: State, mediaId: string) {
const prevSize = state.media_attachments.size;
function removeMedia(compose: Compose, mediaId: string) {
const prevSize = compose.media_attachments.size;
return state.withMutations(map => {
return compose.withMutations(map => {
map.update('media_attachments', list => list.filterNot(item => item.id === mediaId));
map.set('idempotencyKey', uuid());
@ -176,8 +174,8 @@ function removeMedia(state: State, mediaId: string) {
});
}
const insertSuggestion = (state: State, position: number, token: string, completion: string, path: Array<string | number>) => {
return state.withMutations(map => {
const insertSuggestion = (compose: Compose, position: number, token: string, completion: string, path: Array<string | number>) => {
return compose.withMutations(map => {
map.updateIn(path, oldText => `${(oldText as string).slice(0, position)}${completion} ${(oldText as string).slice(position + token.length)}`);
map.set('suggestion_token', null);
map.set('suggestions', ImmutableList());
@ -189,10 +187,10 @@ const insertSuggestion = (state: State, position: number, token: string, complet
});
};
const updateSuggestionTags = (state: State, token: string, currentTrends: ImmutableList<Tag>) => {
const updateSuggestionTags = (compose: Compose, token: string, currentTrends: ImmutableList<Tag>) => {
const prefix = token.slice(1);
return state.merge({
return compose.merge({
suggestions: ImmutableList(currentTrends
.filter((tag) => tag.get('name').toLowerCase().startsWith(prefix.toLowerCase()))
.slice(0, 4)
@ -201,11 +199,11 @@ const updateSuggestionTags = (state: State, token: string, currentTrends: Immuta
});
};
const insertEmoji = (state: State, position: number, emojiData: Emoji, needsSpace: boolean) => {
const oldText = state.text;
const insertEmoji = (compose: Compose, position: number, emojiData: Emoji, needsSpace: boolean) => {
const oldText = compose.text;
const emoji = needsSpace ? ' ' + emojiData.native : emojiData.native;
return state.merge({
return compose.merge({
text: `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`,
focusDate: new Date(),
caretPosition: position + emoji.length + 1,
@ -246,13 +244,13 @@ const getAccountSettings = (account: ImmutableMap<string, any>) => {
return account.getIn(['pleroma', 'settings_store', FE_NAME], ImmutableMap()) as ImmutableMap<string, any>;
};
const importAccount = (state: State, account: APIEntity) => {
const importAccount = (compose: Compose, account: APIEntity) => {
const settings = getAccountSettings(ImmutableMap(fromJS(account)));
const defaultPrivacy = settings.get('defaultPrivacy', 'public');
const defaultContentType = settings.get('defaultContentType', 'text/plain');
return state.merge({
return compose.merge({
default_privacy: defaultPrivacy,
privacy: defaultPrivacy,
default_content_type: defaultContentType,
@ -261,85 +259,87 @@ const importAccount = (state: State, account: APIEntity) => {
});
};
const updateAccount = (state: State, account: APIEntity) => {
const updateAccount = (compose: Compose, account: APIEntity) => {
const settings = getAccountSettings(ImmutableMap(fromJS(account)));
const defaultPrivacy = settings.get('defaultPrivacy');
const defaultContentType = settings.get('defaultContentType');
return state.withMutations(state => {
if (defaultPrivacy) state.set('default_privacy', defaultPrivacy);
if (defaultContentType) state.set('default_content_type', defaultContentType);
return compose.withMutations(compose => {
if (defaultPrivacy) compose.set('default_privacy', defaultPrivacy);
if (defaultContentType) compose.set('default_content_type', defaultContentType);
});
};
const updateSetting = (state: State, path: string[], value: string) => {
const updateSetting = (compose: Compose, path: string[], value: string) => {
const pathString = path.join(',');
switch (pathString) {
case 'defaultPrivacy':
return state.set('default_privacy', value).set('privacy', value);
return compose.set('default_privacy', value).set('privacy', value);
case 'defaultContentType':
return state.set('default_content_type', value).set('content_type', value);
return compose.set('default_content_type', value).set('content_type', value);
default:
return state;
return compose;
}
};
export default function compose(state = ReducerRecord({ idempotencyKey: uuid(), resetFileKey: getResetFileKey() }), action: AnyAction) {
const updateCompose = (state: State, key: string, updater: (compose: Compose) => Compose) =>
state.update(key, ReducerCompose(), updater);
const initialState: State = ImmutableMap({
default: ReducerCompose(),
home: ReducerCompose(),
});
export default function compose(state = initialState, action: AnyAction) {
switch (action.type) {
case COMPOSE_MOUNT:
return state.set('mounted', state.mounted + 1);
case COMPOSE_UNMOUNT:
return state
.set('mounted', Math.max(state.mounted - 1, 0))
.set('is_composing', false);
case COMPOSE_SENSITIVITY_CHANGE:
return state.withMutations(map => {
if (!state.spoiler) {
map.set('sensitive', !state.sensitive);
return updateCompose(state, action.id, compose => compose.withMutations(map => {
if (!compose.spoiler) {
map.set('sensitive', !compose.sensitive);
}
map.set('idempotencyKey', uuid());
});
}));
case COMPOSE_TYPE_CHANGE:
return state.withMutations(map => {
return updateCompose(state, action.id, compose => compose.withMutations(map => {
map.set('content_type', action.value);
map.set('idempotencyKey', uuid());
});
}));
case COMPOSE_SPOILERNESS_CHANGE:
return state.withMutations(map => {
return updateCompose(state, action.id, compose => compose.withMutations(map => {
map.set('spoiler_text', '');
map.set('spoiler', !state.spoiler);
map.set('spoiler', !compose.spoiler);
map.set('idempotencyKey', uuid());
if (!state.sensitive && state.media_attachments.size >= 1) {
if (!compose.sensitive && compose.media_attachments.size >= 1) {
map.set('sensitive', true);
}
});
}));
case COMPOSE_SPOILER_TEXT_CHANGE:
return state
return updateCompose(state, action.id, compose => compose
.set('spoiler_text', action.text)
.set('idempotencyKey', uuid());
.set('idempotencyKey', uuid()));
case COMPOSE_VISIBILITY_CHANGE:
return state
return updateCompose(state, action.id, compose => compose
.set('privacy', action.value)
.set('idempotencyKey', uuid());
.set('idempotencyKey', uuid()));
case COMPOSE_CHANGE:
return state
return updateCompose(state, action.id, compose => compose
.set('text', action.text)
.set('idempotencyKey', uuid());
.set('idempotencyKey', uuid()));
case COMPOSE_COMPOSING_CHANGE:
return state.set('is_composing', action.value);
return updateCompose(state, action.id, compose => compose.set('is_composing', action.value));
case COMPOSE_REPLY:
return state.withMutations(map => {
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
map.set('in_reply_to', action.status.get('id'));
map.set('to', action.explicitAddressing ? statusToMentionsArray(action.status, action.account) : ImmutableOrderedSet<string>());
map.set('text', !action.explicitAddressing ? statusToTextMentions(state, action.status, action.account) : '');
map.set('privacy', privacyPreference(action.status.visibility, state.default_privacy));
map.set('text', !action.explicitAddressing ? statusToTextMentions(action.status, action.account) : '');
map.set('privacy', privacyPreference(action.status.visibility, compose.default_privacy));
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('content_type', state.default_content_type);
map.set('content_type', compose.default_content_type);
if (action.status.get('spoiler_text', '').length > 0) {
map.set('spoiler', true);
@ -348,80 +348,82 @@ export default function compose(state = ReducerRecord({ idempotencyKey: uuid(),
map.set('spoiler', false);
map.set('spoiler_text', '');
}
});
}));
case COMPOSE_QUOTE:
return state.withMutations(map => {
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
map.set('quote', action.status.get('id'));
map.set('to', ImmutableOrderedSet());
map.set('text', '');
map.set('privacy', privacyPreference(action.status.visibility, state.default_privacy));
map.set('privacy', privacyPreference(action.status.visibility, compose.default_privacy));
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('content_type', state.default_content_type);
map.set('content_type', compose.default_content_type);
map.set('spoiler', false);
map.set('spoiler_text', '');
});
}));
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
return updateCompose(state, action.id, compose => compose.set('is_submitting', true));
case COMPOSE_UPLOAD_CHANGE_REQUEST:
return state.set('is_changing_upload', true);
return updateCompose(state, action.id, compose => compose.set('is_changing_upload', true));
case COMPOSE_REPLY_CANCEL:
case COMPOSE_QUOTE_CANCEL:
case COMPOSE_RESET:
case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state);
return updateCompose(state, action.id, clearAll);
case COMPOSE_SUBMIT_FAIL:
return state.set('is_submitting', false);
return updateCompose(state, action.id, compose => compose.set('is_submitting', false));
case COMPOSE_UPLOAD_CHANGE_FAIL:
return state.set('is_changing_upload', false);
return updateCompose(state, action.id, compose => compose.set('is_changing_upload', false));
case COMPOSE_UPLOAD_REQUEST:
return state.set('is_uploading', true);
return updateCompose(state, action.id, compose => compose.set('is_uploading', true));
case COMPOSE_UPLOAD_SUCCESS:
return appendMedia(state, fromJS(action.media));
return updateCompose(state, action.id, compose => appendMedia(compose, fromJS(action.media)));
case COMPOSE_UPLOAD_FAIL:
return state.set('is_uploading', false);
return updateCompose(state, action.id, compose => compose.set('is_uploading', false));
case COMPOSE_UPLOAD_UNDO:
return removeMedia(state, action.media_id);
return updateCompose(state, action.id, compose => removeMedia(compose, action.media_id));
case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100));
return updateCompose(state, action.id, compose => compose.set('progress', Math.round((action.loaded / action.total) * 100)));
case COMPOSE_MENTION:
return state.withMutations(map => {
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
});
}));
case COMPOSE_DIRECT:
return state.withMutations(map => {
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
map.set('privacy', 'direct');
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
});
}));
case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', list => list.clear()).set('suggestion_token', null);
return updateCompose(state, action.id, compose => compose.update('suggestions', list => list.clear()).set('suggestion_token', null));
case COMPOSE_SUGGESTIONS_READY:
return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map((item: APIEntity) => item.id) : action.emojis)).set('suggestion_token', action.token);
return updateCompose(state, action.id, compose => compose.set('suggestions', ImmutableList(action.accounts ? action.accounts.map((item: APIEntity) => item.id) : action.emojis)).set('suggestion_token', action.token));
case COMPOSE_SUGGESTION_SELECT:
return insertSuggestion(state, action.position, action.token, action.completion, action.path);
return updateCompose(state, action.id, compose => insertSuggestion(compose, action.position, action.token, action.completion, action.path));
case COMPOSE_SUGGESTION_TAGS_UPDATE:
return updateSuggestionTags(state, action.token, action.currentTrends);
return updateCompose(state, action.id, compose => updateSuggestionTags(compose, action.token, action.currentTrends));
case COMPOSE_TAG_HISTORY_UPDATE:
return state.set('tagHistory', ImmutableList(fromJS(action.tags)) as ImmutableList<string>);
return updateCompose(state, action.id, compose => compose.set('tagHistory', ImmutableList(fromJS(action.tags)) as ImmutableList<string>));
case TIMELINE_DELETE:
if (action.id === state.in_reply_to) {
return state.set('in_reply_to', null);
} if (action.id === state.quote) {
return state.set('quote', null);
} else {
return state;
}
return updateCompose(state, action.id, compose => {
if (action.id === compose.in_reply_to) {
return compose.set('in_reply_to', null);
} if (action.id === compose.quote) {
return compose.set('quote', null);
} else {
return compose;
}
});
case COMPOSE_EMOJI_INSERT:
return insertEmoji(state, action.position, action.emoji, action.needsSpace);
return updateCompose(state, action.id, compose => insertEmoji(compose, action.position, action.emoji, action.needsSpace));
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
return state
return updateCompose(state, action.id, compose => compose
.set('is_changing_upload', false)
.update('media_attachments', list => list.map(item => {
if (item.id === action.media.id) {
@ -429,9 +431,9 @@ export default function compose(state = ReducerRecord({ idempotencyKey: uuid(),
}
return item;
}));
})));
case COMPOSE_SET_STATUS:
return state.withMutations(map => {
return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
if (!action.withRedraft) {
map.set('id', action.status.get('id'));
}
@ -466,35 +468,35 @@ export default function compose(state = ReducerRecord({ idempotencyKey: uuid(),
expires_in: 24 * 3600,
}));
}
});
}));
case COMPOSE_POLL_ADD:
return state.set('poll', PollRecord());
return updateCompose(state, action.id, compose => compose.set('poll', PollRecord()));
case COMPOSE_POLL_REMOVE:
return state.set('poll', null);
return updateCompose(state, action.id, compose => compose.set('poll', null));
case COMPOSE_SCHEDULE_ADD:
return state.set('schedule', new Date());
return updateCompose(state, action.id, compose => compose.set('schedule', new Date()));
case COMPOSE_SCHEDULE_SET:
return state.set('schedule', action.date);
return updateCompose(state, action.id, compose => compose.set('schedule', action.date));
case COMPOSE_SCHEDULE_REMOVE:
return state.set('schedule', null);
return updateCompose(state, action.id, compose => compose.set('schedule', null));
case COMPOSE_POLL_OPTION_ADD:
return state.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).push(action.title));
return updateCompose(state, action.id, compose => compose.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).push(action.title)));
case COMPOSE_POLL_OPTION_CHANGE:
return state.setIn(['poll', 'options', action.index], action.title);
return updateCompose(state, action.id, compose => compose.setIn(['poll', 'options', action.index], action.title));
case COMPOSE_POLL_OPTION_REMOVE:
return state.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).delete(action.index));
return updateCompose(state, action.id, compose => compose.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).delete(action.index)));
case COMPOSE_POLL_SETTINGS_CHANGE:
return state.update('poll', poll => poll!.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
return updateCompose(state, action.id, compose => compose.update('poll', poll => poll!.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)));
case COMPOSE_ADD_TO_MENTIONS:
return state.update('to', mentions => mentions!.add(action.account));
return updateCompose(state, action.id, compose => compose.update('to', mentions => mentions!.add(action.account)));
case COMPOSE_REMOVE_FROM_MENTIONS:
return state.update('to', mentions => mentions!.delete(action.account));
return updateCompose(state, action.id, compose => compose.update('to', mentions => mentions!.delete(action.account)));
case ME_FETCH_SUCCESS:
return importAccount(state, action.me);
return updateCompose(state, 'default', compose => importAccount(compose, action.me));
case ME_PATCH_SUCCESS:
return updateAccount(state, action.me);
return updateCompose(state, 'default', compose => updateAccount(compose, action.me));
case SETTING_CHANGE:
return updateSetting(state, action.path, action.value);
return updateCompose(state, 'default', compose => updateSetting(compose, action.path, action.value));
default:
return state;
}