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