Signed-off-by: marcin mikołajczak <git@mkljczk.pl>environments/review-events-5jp5it/deployments/1372
commit
640000c18e
@ -0,0 +1,367 @@
|
||||
import classNames from 'clsx';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { length } from 'stringz';
|
||||
|
||||
import {
|
||||
changeCompose,
|
||||
submitCompose,
|
||||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions,
|
||||
selectComposeSuggestion,
|
||||
changeComposeSpoilerText,
|
||||
insertEmojiCompose,
|
||||
uploadCompose,
|
||||
} from 'soapbox/actions/compose';
|
||||
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest_input';
|
||||
import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Button, Stack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
|
||||
import { isMobile } from 'soapbox/is_mobile';
|
||||
|
||||
import EmojiPickerDropdown from '../components/emoji-picker/emoji-picker-dropdown';
|
||||
import MarkdownButton from '../components/markdown_button';
|
||||
import PollButton from '../components/poll_button';
|
||||
import PollForm from '../components/polls/poll-form';
|
||||
import PrivacyDropdown from '../components/privacy_dropdown';
|
||||
import ReplyMentions from '../components/reply_mentions';
|
||||
import ScheduleButton from '../components/schedule_button';
|
||||
import SpoilerButton from '../components/spoiler_button';
|
||||
import UploadForm from '../components/upload_form';
|
||||
import Warning from '../components/warning';
|
||||
import QuotedStatusContainer from '../containers/quoted_status_container';
|
||||
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
|
||||
import ScheduleFormContainer from '../containers/schedule_form_container';
|
||||
import UploadButtonContainer from '../containers/upload_button_container';
|
||||
import WarningContainer from '../containers/warning_container';
|
||||
import { countableText } from '../util/counter';
|
||||
|
||||
import TextCharacterCounter from './text_character_counter';
|
||||
import VisualCharacterCounter from './visual_character_counter';
|
||||
|
||||
import type { Emoji } from 'soapbox/components/autosuggest_emoji';
|
||||
|
||||
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
|
||||
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic...' },
|
||||
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
|
||||
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
||||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
||||
message: { id: 'compose_form.message', defaultMessage: 'Message' },
|
||||
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
|
||||
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
|
||||
});
|
||||
|
||||
interface IComposeForm {
|
||||
id: string,
|
||||
shouldCondense?: boolean,
|
||||
autoFocus?: boolean,
|
||||
clickableAreaRef?: React.RefObject<HTMLDivElement>,
|
||||
}
|
||||
|
||||
const ComposeForm: React.FC<IComposeForm> = ({ id, shouldCondense, autoFocus, clickableAreaRef }) => {
|
||||
const history = useHistory();
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const compose = useCompose(id);
|
||||
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden);
|
||||
const isModalOpen = useAppSelector((state) => !!(state.modals.size && state.modals.last()!.modalType === 'COMPOSE'));
|
||||
const maxTootChars = useAppSelector((state) => state.instance.getIn(['configuration', 'statuses', 'max_characters'])) as number;
|
||||
const scheduledStatusCount = useAppSelector((state) => state.get('scheduled_statuses').size);
|
||||
const features = useFeatures();
|
||||
|
||||
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose;
|
||||
|
||||
const hasPoll = !!compose.poll;
|
||||
const isEditing = compose.id !== null;
|
||||
const anyMedia = compose.media_attachments.size > 0;
|
||||
|
||||
const [composeFocused, setComposeFocused] = useState(false);
|
||||
|
||||
const formRef = useRef(null);
|
||||
const spoilerTextRef = useRef<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(id));
|
||||
};
|
||||
|
||||
const onSuggestionsFetchRequested = (token: string | number) => {
|
||||
dispatch(fetchComposeSuggestions(id, token as string));
|
||||
};
|
||||
|
||||
const onSuggestionSelected = (tokenStart: number, token: string | null, value: string | undefined) => {
|
||||
if (value) dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['text']));
|
||||
};
|
||||
|
||||
const onSpoilerSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
|
||||
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
|
||||
};
|
||||
|
||||
const handleChangeSpoilerText: React.ChangeEventHandler<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 renderButtons = useCallback(() => (
|
||||
<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>
|
||||
), [features, id]);
|
||||
|
||||
const condensed = shouldCondense && !composeFocused && isEmpty() && !isUploading;
|
||||
const disabled = isSubmitting;
|
||||
const countedText = [spoilerText, countableText(text)].join('');
|
||||
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
|
||||
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
|
||||
|
||||
let publishText: string | JSX.Element = '';
|
||||
|
||||
if (isEditing) {
|
||||
publishText = intl.formatMessage(messages.saveChanges);
|
||||
} else if (privacy === 'direct') {
|
||||
publishText = (
|
||||
<>
|
||||
<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,
|
||||
})}
|
||||
>
|
||||
{renderButtons()}
|
||||
|
||||
<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;
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,209 @@
|
||||
import classNames from 'clsx';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
// @ts-ignore
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { useEmoji } from 'soapbox/actions/emojis';
|
||||
import { getSettings, changeSetting } from 'soapbox/actions/settings';
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import { EmojiPicker as EmojiPickerAsync } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import EmojiPickerMenu from './emoji-picker-menu';
|
||||
|
||||
import type { Emoji as EmojiType } from 'soapbox/components/autosuggest_emoji';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
let EmojiPicker: any, Emoji: any; // load asynchronously
|
||||
|
||||
const perLine = 8;
|
||||
const lines = 2;
|
||||
|
||||
const DEFAULTS = [
|
||||
'+1',
|
||||
'grinning',
|
||||
'kissing_heart',
|
||||
'heart_eyes',
|
||||
'laughing',
|
||||
'stuck_out_tongue_winking_eye',
|
||||
'sweat_smile',
|
||||
'joy',
|
||||
'yum',
|
||||
'disappointed',
|
||||
'thinking_face',
|
||||
'weary',
|
||||
'sob',
|
||||
'sunglasses',
|
||||
'heart',
|
||||
'ok_hand',
|
||||
];
|
||||
|
||||
const getFrequentlyUsedEmojis = createSelector([
|
||||
(state: RootState) => state.settings.get('frequentlyUsedEmojis', ImmutableMap()),
|
||||
], emojiCounters => {
|
||||
let emojis = emojiCounters
|
||||
.keySeq()
|
||||
.sort((a: number, b: number) => emojiCounters.get(a) - emojiCounters.get(b))
|
||||
.reverse()
|
||||
.slice(0, perLine * lines)
|
||||
.toArray();
|
||||
|
||||
if (emojis.length < DEFAULTS.length) {
|
||||
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
|
||||
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
|
||||
}
|
||||
|
||||
return emojis;
|
||||
});
|
||||
|
||||
const getCustomEmojis = createSelector([
|
||||
(state: RootState) => state.custom_emojis as ImmutableList<ImmutableMap<string, string>>,
|
||||
], emojis => emojis.filter((e) => e.get('visible_in_picker')).sort((a, b) => {
|
||||
const aShort = a.get('shortcode')!.toLowerCase();
|
||||
const bShort = b.get('shortcode')!.toLowerCase();
|
||||
|
||||
if (aShort < bShort) {
|
||||
return -1;
|
||||
} else if (aShort > bShort) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}) as ImmutableList<ImmutableMap<string, string>>);
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
|
||||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
|
||||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
|
||||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
|
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
|
||||
people: { id: 'emoji_button.people', defaultMessage: 'People' },
|
||||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
|
||||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
|
||||
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
|
||||
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
|
||||
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
|
||||
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
|
||||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
|
||||
});
|
||||
|
||||
interface IEmojiPickerDropdown {
|
||||
onPickEmoji: (data: EmojiType) => void,
|
||||
button?: JSX.Element,
|
||||
}
|
||||
|
||||
const EmojiPickerDropdown: React.FC<IEmojiPickerDropdown> = ({ onPickEmoji, button }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const customEmojis = useAppSelector((state) => getCustomEmojis(state));
|
||||
const skinTone = useAppSelector((state) => getSettings(state).get('skinTone') as number);
|
||||
const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state));
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [placement, setPlacement] = useState<'bottom' | 'top'>();
|
||||
|
||||
const target = useRef(null);
|
||||
|
||||
const onSkinTone = (skinTone: number) => {
|
||||
dispatch(changeSetting(['skinTone'], skinTone));
|
||||
};
|
||||
|
||||
const handlePickEmoji = (emoji: EmojiType) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
dispatch(useEmoji(emoji));
|
||||
|
||||
if (onPickEmoji) {
|
||||
onPickEmoji(emoji);
|
||||
}
|
||||
};
|
||||
|
||||
const onShowDropdown: React.EventHandler<React.KeyboardEvent | React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setActive(true);
|
||||
|
||||
if (!EmojiPicker) {
|
||||
setLoading(true);
|
||||
|
||||
EmojiPickerAsync().then(EmojiMart => {
|
||||
EmojiPicker = EmojiMart.Picker;
|
||||
Emoji = EmojiMart.Emoji;
|
||||
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
const { top } = (e.target as any).getBoundingClientRect();
|
||||
setPlacement(top * 2 < innerHeight ? 'bottom' : 'top');
|
||||
};
|
||||
|
||||
const onHideDropdown = () => {
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
const onToggle: React.EventHandler<React.KeyboardEvent | React.MouseEvent> = (e) => {
|
||||
if (!loading && (!(e as React.KeyboardEvent).key || (e as React.KeyboardEvent).key === 'Enter')) {
|
||||
if (active) {
|
||||
onHideDropdown();
|
||||
} else {
|
||||
onShowDropdown(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler = e => {
|
||||
if (e.key === 'Escape') {
|
||||
onHideDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
|
||||
return (
|
||||
<div className='relative' onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
ref={target}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-expanded={active}
|
||||
role='button'
|
||||
onClick={onToggle}
|
||||
onKeyDown={onToggle}
|
||||
tabIndex={0}
|
||||
>
|
||||
{button || <IconButton
|
||||
className={classNames({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
|
||||
'pulse-loading': active && loading,
|
||||
})}
|
||||
title='😀'
|
||||
src={require('@tabler/icons/mood-happy.svg')}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<Overlay show={active} placement={placement} target={target.current}>
|
||||
<EmojiPickerMenu
|
||||
customEmojis={customEmojis}
|
||||
loading={loading}
|
||||
onClose={onHideDropdown}
|
||||
onPick={handlePickEmoji}
|
||||
onSkinTone={onSkinTone}
|
||||
skinTone={skinTone}
|
||||
frequentlyUsedEmojis={frequentlyUsedEmojis}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { EmojiPicker, Emoji };
|
||||
|
||||
export default EmojiPickerDropdown;
|
@ -0,0 +1,170 @@
|
||||
import classNames from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { buildCustomEmojis } from '../../../emoji/emoji';
|
||||
|
||||
import { EmojiPicker } from './emoji-picker-dropdown';
|
||||
import ModifierPicker from './modifier-picker';
|
||||
|
||||
import type { Emoji } from 'soapbox/components/autosuggest_emoji';
|
||||
|
||||
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
const categoriesSort = [
|
||||
'recent',
|
||||
'custom',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
];
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
|
||||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
|
||||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
|
||||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
|
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
|
||||
people: { id: 'emoji_button.people', defaultMessage: 'People' },
|
||||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
|
||||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
|
||||
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
|
||||
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
|
||||
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
|
||||
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
|
||||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
|
||||
});
|
||||
|
||||
interface IEmojiPickerMenu {
|
||||
customEmojis: ImmutableList<ImmutableMap<string, string>>,
|
||||
loading?: boolean,
|
||||
onClose: () => void,
|
||||
onPick: (emoji: Emoji) => void,
|
||||
onSkinTone: (skinTone: number) => void,
|
||||
skinTone?: number,
|
||||
frequentlyUsedEmojis?: Array<string>,
|
||||
style?: React.CSSProperties,
|
||||
}
|
||||
|
||||
const EmojiPickerMenu: React.FC<IEmojiPickerMenu> = ({
|
||||
customEmojis,
|
||||
loading = true,
|
||||
onClose,
|
||||
onPick,
|
||||
onSkinTone,
|
||||
skinTone,
|
||||
frequentlyUsedEmojis = [],
|
||||
style = {},
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [modifierOpen, setModifierOpen] = useState(false);
|
||||
|
||||
const handleDocumentClick = useCallback(e => {
|
||||
if (node.current && !node.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getI18n = () => {
|
||||
return {
|
||||
search: intl.formatMessage(messages.emoji_search),
|
||||
notfound: intl.formatMessage(messages.emoji_not_found),
|
||||
categories: {
|
||||
search: intl.formatMessage(messages.search_results),
|
||||
recent: intl.formatMessage(messages.recent),
|
||||
people: intl.formatMessage(messages.people),
|
||||
nature: intl.formatMessage(messages.nature),
|
||||
foods: intl.formatMessage(messages.food),
|
||||
activity: intl.formatMessage(messages.activity),
|
||||
places: intl.formatMessage(messages.travel),
|
||||
objects: intl.formatMessage(messages.objects),
|
||||
symbols: intl.formatMessage(messages.symbols),
|
||||
flags: intl.formatMessage(messages.flags),
|
||||
custom: intl.formatMessage(messages.custom),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleClick = (emoji: any) => {
|
||||
if (!emoji.native) {
|
||||
emoji.native = emoji.colons;
|
||||
}
|
||||
|
||||
onClose();
|
||||
onPick(emoji);
|
||||
};
|
||||
|
||||
const handleModifierOpen = () => {
|
||||
setModifierOpen(true);
|
||||
};
|
||||
|
||||
const handleModifierClose = () => {
|
||||
setModifierOpen(false);
|
||||
};
|
||||
|
||||
const handleModifierChange = (modifier: number) => {
|
||||
onSkinTone(modifier);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, false);
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ width: 299 }} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
|
||||
return (
|
||||
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={node}>
|
||||
<EmojiPicker
|
||||
perLine={8}
|
||||
emojiSize={22}
|
||||
sheetSize={32}
|
||||
custom={buildCustomEmojis(customEmojis)}
|
||||
color=''
|
||||
emoji=''
|
||||
set='twitter'
|
||||
title={title}
|
||||
i18n={getI18n()}
|
||||
onClick={handleClick}
|
||||
include={categoriesSort}
|
||||
recent={frequentlyUsedEmojis}
|
||||
skin={skinTone}
|
||||
showPreview={false}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
autoFocus
|
||||
emojiTooltip
|
||||
/>
|
||||
|
||||
<ModifierPicker
|
||||
active={modifierOpen}
|
||||
modifier={skinTone}
|
||||
onOpen={handleModifierOpen}
|
||||
onClose={handleModifierClose}
|
||||
onChange={handleModifierChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPickerMenu;
|
@ -0,0 +1,73 @@
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { Emoji } from './emoji-picker-dropdown';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
|
||||
|
||||
interface IModifierPickerMenu {
|
||||
active: boolean,
|
||||
onSelect: (modifier: number) => void,
|
||||
onClose: () => void,
|
||||
}
|
||||
|
||||
const ModifierPickerMenu: React.FC<IModifierPickerMenu> = ({ active, onSelect, onClose }) => {
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = e => {
|
||||
onSelect(+e.currentTarget.getAttribute('data-index')! * 1);
|
||||
};
|
||||
|
||||
const handleDocumentClick = useCallback((e => {
|
||||
if (node.current && !node.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
}), []);
|
||||
|
||||
const attachListeners = () => {
|
||||
document.addEventListener('click', handleDocumentClick, false);
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
};
|
||||
|
||||
const removeListeners = () => {
|
||||
document.removeEventListener('click', handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeListeners();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) attachListeners();
|
||||
else removeListeners();
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={node}>
|
||||
<button onClick={handleClick} data-index={1}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
<button onClick={handleClick} data-index={2}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
<button onClick={handleClick} data-index={3}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
<button onClick={handleClick} data-index={4}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
<button onClick={handleClick} data-index={5}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
<button onClick={handleClick} data-index={6}>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModifierPickerMenu;
|
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Emoji } from './emoji-picker-dropdown';
|
||||
import ModifierPickerMenu from './modifier-picker-menu';
|
||||
|
||||
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
|
||||
|
||||
interface IModifierPicker {
|
||||
active: boolean,
|
||||
modifier?: number,
|
||||
onOpen: () => void,
|
||||
onClose: () => void,
|
||||
onChange: (skinTone: number) => void,
|
||||
}
|
||||
|
||||
const ModifierPicker: React.FC<IModifierPicker> = ({ active, modifier, onOpen, onClose, onChange }) => {
|
||||
const handleClick = () => {
|
||||
if (active) {
|
||||
onClose();
|
||||
} else {
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (modifier: number) => {
|
||||
onChange(modifier);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers'>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={handleClick} backgroundImageFn={backgroundImageFn} />
|
||||
<ModifierPickerMenu active={active} onSelect={handleSelect} onClose={onClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModifierPicker;
|
@ -1,397 +0,0 @@
|
||||
import classNames from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
|
||||
import { buildCustomEmojis } from '../../emoji/emoji';
|
||||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
|
||||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
|
||||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
|
||||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
|
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
|
||||
people: { id: 'emoji_button.people', defaultMessage: 'People' },
|
||||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
|
||||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
|
||||
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
|
||||
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
|
||||
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
|
||||
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
|
||||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
|
||||
});
|
||||
|
||||
let EmojiPicker, Emoji; // load asynchronously
|
||||
|
||||
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
const categoriesSort = [
|
||||
'recent',
|
||||
'custom',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
];
|
||||
|
||||
class ModifierPickerMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleClick = e => {
|
||||
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.active) {
|
||||
this.attachListeners();
|
||||
} else {
|
||||
this.removeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.removeListeners();
|
||||
}
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
removeListeners() {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { active } = this.props;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
|
||||
<button onClick={this.handleClick} data-index={1}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={2}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={3}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={4}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={5}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={6}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ModifierPicker extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
modifier: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
onOpen: PropTypes.func,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (this.props.active) {
|
||||
this.props.onClose();
|
||||
} else {
|
||||
this.props.onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
handleSelect = modifier => {
|
||||
this.props.onChange(modifier);
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { active, modifier } = this.props;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers'>
|
||||
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
|
||||
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
class EmojiPickerMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
custom_emojis: ImmutablePropTypes.list,
|
||||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
|
||||
loading: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onPick: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
placement: PropTypes.string,
|
||||
arrowOffsetLeft: PropTypes.string,
|
||||
arrowOffsetTop: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
loading: true,
|
||||
frequentlyUsedEmojis: [],
|
||||
};
|
||||
|
||||
state = {
|
||||
modifierOpen: false,
|
||||
placement: null,
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
getI18n = () => {
|
||||
const { intl } = this.props;
|
||||
|
||||
return {
|
||||
search: intl.formatMessage(messages.emoji_search),
|
||||
notfound: intl.formatMessage(messages.emoji_not_found),
|
||||
categories: {
|
||||
search: intl.formatMessage(messages.search_results),
|
||||
recent: intl.formatMessage(messages.recent),
|
||||
people: intl.formatMessage(messages.people),
|
||||
nature: intl.formatMessage(messages.nature),
|
||||
foods: intl.formatMessage(messages.food),
|
||||
activity: intl.formatMessage(messages.activity),
|
||||
places: intl.formatMessage(messages.travel),
|
||||
objects: intl.formatMessage(messages.objects),
|
||||
symbols: intl.formatMessage(messages.symbols),
|
||||
flags: intl.formatMessage(messages.flags),
|
||||
custom: intl.formatMessage(messages.custom),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
handleClick = emoji => {
|
||||
if (!emoji.native) {
|
||||
emoji.native = emoji.colons;
|
||||
}
|
||||
|
||||
this.props.onClose();
|
||||
this.props.onPick(emoji);
|
||||
}
|
||||
|
||||
handleModifierOpen = () => {
|
||||
this.setState({ modifierOpen: true });
|
||||
}
|
||||
|
||||
handleModifierClose = () => {
|
||||
this.setState({ modifierOpen: false });
|
||||
}
|
||||
|
||||
handleModifierChange = modifier => {
|
||||
this.props.onSkinTone(modifier);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ width: 299 }} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { modifierOpen } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
|
||||
<EmojiPicker
|
||||
perLine={8}
|
||||
emojiSize={22}
|
||||
sheetSize={32}
|
||||
custom={buildCustomEmojis(custom_emojis)}
|
||||
color=''
|
||||
emoji=''
|
||||
set='twitter'
|
||||
title={title}
|
||||
i18n={this.getI18n()}
|
||||
onClick={this.handleClick}
|
||||
include={categoriesSort}
|
||||
recent={frequentlyUsedEmojis}
|
||||
skin={skinTone}
|
||||
showPreview={false}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
autoFocus
|
||||
emojiTooltip
|
||||
/>
|
||||
|
||||
<ModifierPicker
|
||||
active={modifierOpen}
|
||||
modifier={skinTone}
|
||||
onOpen={this.handleModifierOpen}
|
||||
onClose={this.handleModifierClose}
|
||||
onChange={this.handleModifierChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default @injectIntl
|
||||
class EmojiPickerDropdown extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
custom_emojis: ImmutablePropTypes.list,
|
||||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
|
||||
intl: PropTypes.object.isRequired,
|
||||
onPickEmoji: PropTypes.func.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
button: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
active: false,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
setRef = (c) => {
|
||||
this.dropdown = c;
|
||||
}
|
||||
|
||||
onShowDropdown = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
this.setState({ active: true });
|
||||
|
||||
if (!EmojiPicker) {
|
||||
this.setState({ loading: true });
|
||||
|
||||
EmojiPickerAsync().then(EmojiMart => {
|
||||
EmojiPicker = EmojiMart.Picker;
|
||||
Emoji = EmojiMart.Emoji;
|
||||
|
||||
this.setState({ loading: false });
|
||||
}).catch(() => {
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
}
|
||||
|
||||
const { top } = e.target.getBoundingClientRect();
|
||||
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
|
||||
}
|
||||
|
||||
onHideDropdown = () => {
|
||||
this.setState({ active: false });
|
||||
}
|
||||
|
||||
onToggle = (e) => {
|
||||
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
|
||||
if (this.state.active) {
|
||||
this.onHideDropdown();
|
||||
} else {
|
||||
this.onShowDropdown(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
if (e.key === 'Escape') {
|
||||
this.onHideDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
}
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { active, loading, placement } = this.state;
|
||||
|
||||
return (
|
||||
<div className='relative' onKeyDown={this.handleKeyDown}>
|
||||
<div
|
||||
ref={this.setTargetRef}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-expanded={active}
|
||||
role='button'
|
||||
onClick={this.onToggle}
|
||||
onKeyDown={this.onToggle}
|
||||
tabIndex={0}
|
||||
>
|
||||
{button || <IconButton
|
||||
className={classNames({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
|
||||
'pulse-loading': active && loading,
|
||||
})}
|
||||
alt='😀'
|
||||
src={require('@tabler/icons/mood-happy.svg')}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<Overlay show={active} placement={placement} target={this.findTarget}>
|
||||
<EmojiPickerMenu
|
||||
custom_emojis={this.props.custom_emojis}
|
||||
loading={loading}
|
||||
onClose={this.onHideDropdown}
|
||||
onPick={onPickEmoji}
|
||||
onSkinTone={onSkinTone}
|
||||
skinTone={skinTone}
|
||||
frequentlyUsedEmojis={frequentlyUsedEmojis}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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(value) {
|
||||
dispatch(changeComposeSpoilerText(value));
|
||||
},
|
||||
|
||||
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));
|
@ -1,84 +0,0 @@
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { useEmoji } from '../../../actions/emojis';
|
||||
import { getSettings, changeSetting } from '../../../actions/settings';
|
||||
import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
|
||||
|
||||
const perLine = 8;
|
||||
const lines = 2;
|
||||
|
||||
const DEFAULTS = [
|
||||
'+1',
|
||||
'grinning',
|
||||
'kissing_heart',
|
||||
'heart_eyes',
|
||||
'laughing',
|
||||
'stuck_out_tongue_winking_eye',
|
||||
'sweat_smile',
|
||||
'joy',
|
||||
'yum',
|
||||
'disappointed',
|
||||
'thinking_face',
|
||||
'weary',
|
||||
'sob',
|
||||
'sunglasses',
|
||||
'heart',
|
||||
'ok_hand',
|
||||
];
|
||||
|
||||
const getFrequentlyUsedEmojis = createSelector([
|
||||
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
|
||||
], emojiCounters => {
|
||||
let emojis = emojiCounters
|
||||
.keySeq()
|
||||
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
|
||||
.reverse()
|
||||
.slice(0, perLine * lines)
|
||||
.toArray();
|
||||
|
||||
if (emojis.length < DEFAULTS.length) {
|
||||
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
|
||||
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
|
||||
}
|
||||
|
||||
return emojis;
|
||||
});
|
||||
|
||||
const getCustomEmojis = createSelector([
|
||||
state => state.get('custom_emojis'),
|
||||
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
|
||||
const aShort = a.get('shortcode').toLowerCase();
|
||||
const bShort = b.get('shortcode').toLowerCase();
|
||||
|
||||
if (aShort < bShort) {
|
||||
return -1;
|
||||
} else if (aShort > bShort) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}));
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
custom_emojis: getCustomEmojis(state),
|
||||
skinTone: getSettings(state).get('skinTone'),
|
||||
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, props) => ({
|
||||
onSkinTone: skinTone => {
|
||||
dispatch(changeSetting(['skinTone'], skinTone));
|
||||
},
|
||||
|
||||
onPickEmoji: emoji => {
|
||||
dispatch(useEmoji(emoji)); // eslint-disable-line react-hooks/rules-of-hooks
|
||||
|
||||
if (props.onPickEmoji) {
|
||||
props.onPickEmoji(emoji);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
@ -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);
|
@ -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));
|
@ -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);
|
@ -1,37 +0,0 @@
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { undoUploadCompose, changeUploadCompose, submitCompose } from '../../../actions/compose';
|
||||
import { openModal } from '../../../actions/modals';
|
||||
import Upload from '../components/upload';
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
descriptionLimit: state.getIn(['instance', 'description_limit']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onUndo: id => {
|
||||
dispatch(undoUploadCompose(id));
|
||||
},
|
||||
|
||||
onDescriptionChange: (id, description) => {
|
||||
dispatch(changeUploadCompose(id, { description }));
|
||||
},
|
||||
|
||||
onOpenFocalPoint: id => {
|
||||
dispatch(openModal('FOCAL_POINT', { id }));
|
||||
},
|
||||
|
||||
onOpenModal: media => {
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 }));
|
||||
},
|
||||
|
||||
onSubmit(router) {
|
||||
dispatch(submitCompose(router));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Upload);
|
@ -1,10 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import UploadProgress from '../components/upload-progress';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
active: state.getIn(['compose', 'is_uploading']),
|
||||
progress: state.getIn(['compose', 'progress']),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(UploadProgress);
|
@ -0,0 +1,8 @@
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import type { ReducerCompose } from 'soapbox/reducers/compose';
|
||||
|
||||
/** Get compose for given key with fallback to 'default' */
|
||||
export const useCompose = <ID extends string>(composeId: ID extends 'default' ? never : ID): ReturnType<typeof ReducerCompose> => {
|
||||
return useAppSelector((state) => state.compose.get(composeId, state.compose.get('default')!));
|
||||
};
|
Loading…
Reference in new issue