diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 32746f27b62..d87786008cd 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -316,21 +316,14 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
export function selectComposeSuggestion(position, token, suggestion) {
return (dispatch, getState) => {
- let completion, startPosition;
-
- if (typeof suggestion === 'object' && suggestion.id) {
- completion = suggestion.native || suggestion.colons;
- startPosition = position - 1;
-
- dispatch(useEmoji(suggestion));
- } else {
- completion = getState().getIn(['accounts', suggestion, 'acct']);
- startPosition = position;
- }
+ const completion = typeof suggestion === 'object' && suggestion.id ? (
+ dispatch(useEmoji(suggestion)),
+ suggestion.native || suggestion.colons
+ ) : '@' + getState().getIn(['accounts', suggestion, 'acct']);
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
- position: startPosition,
+ position,
token,
completion,
});
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index a1f0754919a..ced18b34819 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -30,6 +30,7 @@ export default class Account extends ImmutablePureComponent {
onMuteNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
+ small: PropTypes.bool,
};
handleFollow = () => {
@@ -53,7 +54,12 @@ export default class Account extends ImmutablePureComponent {
}
render () {
- const { account, intl, hidden } = this.props;
+ const {
+ account,
+ hidden,
+ intl,
+ small,
+ } = this.props;
if (!account) {
return
;
@@ -70,7 +76,7 @@ export default class Account extends ImmutablePureComponent {
let buttons;
- if (account.get('id') !== me && account.get('relationship', null) !== null) {
+ if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
@@ -98,17 +104,23 @@ export default class Account extends ImmutablePureComponent {
}
}
- return (
+ return small ? (
+
+ ) : (
-
-
- {buttons}
-
+ {buttons ?
+
+ {buttons}
+
+ : null}
);
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
deleted file mode 100644
index 79e113d9cf0..00000000000
--- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
-
-const assetHost = process.env.CDN_HOST || '';
-
-export default class AutosuggestEmoji extends React.PureComponent {
-
- static propTypes = {
- emoji: PropTypes.object.isRequired,
- };
-
- render () {
- const { emoji } = this.props;
- let url;
-
- if (emoji.custom) {
- url = emoji.imageUrl;
- } else {
- const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
-
- if (!mapping) {
- return null;
- }
-
- url = `${assetHost}/emoji/${mapping.filename}.svg`;
- }
-
- return (
-
-
-
- {emoji.colons}
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
deleted file mode 100644
index a29b2c9c57e..00000000000
--- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js
+++ /dev/null
@@ -1,223 +0,0 @@
-import React from 'react';
-import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
-import AutosuggestEmoji from './autosuggest_emoji';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { isRtl } from 'flavours/glitch/util/rtl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Textarea from 'react-textarea-autosize';
-import classNames from 'classnames';
-
-const textAtCursorMatchesToken = (str, caretPosition) => {
- let word;
-
- let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
- let right = str.slice(caretPosition).search(/[\s\u200B]/);
-
- if (right < 0) {
- word = str.slice(left);
- } else {
- word = str.slice(left, right + caretPosition);
- }
-
- if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
- return [null, null];
- }
-
- word = word.trim().toLowerCase();
-
- if (word.length > 0) {
- return [left + 1, word];
- } else {
- return [null, null];
- }
-};
-
-export default class AutosuggestTextarea extends ImmutablePureComponent {
-
- static propTypes = {
- value: PropTypes.string,
- suggestions: ImmutablePropTypes.list,
- disabled: PropTypes.bool,
- placeholder: PropTypes.string,
- onSuggestionSelected: PropTypes.func.isRequired,
- onSuggestionsClearRequested: PropTypes.func.isRequired,
- onSuggestionsFetchRequested: PropTypes.func.isRequired,
- onChange: PropTypes.func.isRequired,
- onKeyUp: PropTypes.func,
- onKeyDown: PropTypes.func,
- onPaste: PropTypes.func.isRequired,
- autoFocus: PropTypes.bool,
- };
-
- static defaultProps = {
- autoFocus: true,
- };
-
- state = {
- suggestionsHidden: false,
- selectedSuggestion: 0,
- lastToken: null,
- tokenStart: 0,
- };
-
- onChange = (e) => {
- const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
-
- if (token !== null && this.state.lastToken !== token) {
- this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
- this.props.onSuggestionsFetchRequested(token);
- } else if (token === null) {
- this.setState({ lastToken: null });
- this.props.onSuggestionsClearRequested();
- }
-
- this.props.onChange(e);
- }
-
- onKeyDown = (e) => {
- const { suggestions, disabled } = this.props;
- const { selectedSuggestion, suggestionsHidden } = this.state;
-
- if (disabled) {
- e.preventDefault();
- return;
- }
-
- switch(e.key) {
- case 'Escape':
- if (!suggestionsHidden) {
- e.preventDefault();
- this.setState({ suggestionsHidden: true });
- }
-
- break;
- case 'ArrowDown':
- if (suggestions.size > 0 && !suggestionsHidden) {
- e.preventDefault();
- this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
- }
-
- break;
- case 'ArrowUp':
- if (suggestions.size > 0 && !suggestionsHidden) {
- e.preventDefault();
- this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
- }
-
- break;
- case 'Enter':
- case 'Tab':
- // Select suggestion
- if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
- e.preventDefault();
- e.stopPropagation();
- this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
- }
-
- break;
- }
-
- if (e.defaultPrevented || !this.props.onKeyDown) {
- return;
- }
-
- this.props.onKeyDown(e);
- }
-
- onKeyUp = e => {
- if (e.key === 'Escape' && this.state.suggestionsHidden) {
- document.querySelector('.ui').parentElement.focus();
- }
-
- if (this.props.onKeyUp) {
- this.props.onKeyUp(e);
- }
- }
-
- onBlur = () => {
- this.setState({ suggestionsHidden: true });
- }
-
- onSuggestionClick = (e) => {
- const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
- e.preventDefault();
- this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
- this.textarea.focus();
- }
-
- componentWillReceiveProps (nextProps) {
- if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
- this.setState({ suggestionsHidden: false });
- }
- }
-
- setTextarea = (c) => {
- this.textarea = c;
- }
-
- onPaste = (e) => {
- if (e.clipboardData && e.clipboardData.files.length === 1) {
- this.props.onPaste(e.clipboardData.files);
- e.preventDefault();
- }
- }
-
- renderSuggestion = (suggestion, i) => {
- const { selectedSuggestion } = this.state;
- let inner, key;
-
- if (typeof suggestion === 'object') {
- inner = ;
- key = suggestion.id;
- } else {
- inner = ;
- key = suggestion;
- }
-
- return (
-
- {inner}
-
- );
- }
-
- render () {
- const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
- const { suggestionsHidden } = this.state;
- const style = { direction: 'ltr' };
-
- if (isRtl(value)) {
- style.direction = 'rtl';
- }
-
- return (
-
-
- {placeholder}
-
-
-
-
-
- {suggestions.map(this.renderSuggestion)}
-
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/components/icon.js b/app/javascript/flavours/glitch/components/icon.js
new file mode 100644
index 00000000000..8f55a0115e9
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/icon.js
@@ -0,0 +1,26 @@
+// Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+// This just renders a FontAwesome icon.
+export default function Icon ({
+ className,
+ fullwidth,
+ icon,
+}) {
+ const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className);
+ return icon ? (
+
+ ) : null;
+}
+
+// Props.
+Icon.propTypes = {
+ className: PropTypes.string,
+ fullwidth: PropTypes.bool,
+ icon: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/components/text_icon_button.js
similarity index 100%
rename from app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
rename to app/javascript/flavours/glitch/components/text_icon_button.js
diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options.js
deleted file mode 100644
index 045bad2e5d5..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/advanced_options.js
+++ /dev/null
@@ -1,62 +0,0 @@
-// Package imports.
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { injectIntl, defineMessages } from 'react-intl';
-
-// Our imports.
-import ComposeAdvancedOptionsToggle from './advanced_options_toggle';
-import ComposeDropdown from './dropdown';
-
-const messages = defineMessages({
- local_only_short :
- { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
- local_only_long :
- { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
- advanced_options_icon_title :
- { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
-});
-
-@injectIntl
-export default class ComposeAdvancedOptions extends React.PureComponent {
-
- static propTypes = {
- values : ImmutablePropTypes.contains({
- do_not_federate : PropTypes.bool.isRequired,
- }).isRequired,
- onChange : PropTypes.func.isRequired,
- intl : PropTypes.object.isRequired,
- };
-
- render () {
- const { intl, values } = this.props;
- const options = [
- { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
- ];
- const anyEnabled = values.some((enabled) => enabled);
-
- const optionElems = options.map((option) => {
- return (
-
- );
- });
-
- return (
-
- {optionElems}
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js
deleted file mode 100644
index 98b3b6a446b..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js
+++ /dev/null
@@ -1,35 +0,0 @@
-// Package imports.
-import React from 'react';
-import PropTypes from 'prop-types';
-import Toggle from 'react-toggle';
-
-export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
-
- static propTypes = {
- onChange: PropTypes.func.isRequired,
- active: PropTypes.bool.isRequired,
- name: PropTypes.string.isRequired,
- shortText: PropTypes.string.isRequired,
- longText: PropTypes.string.isRequired,
- }
-
- onToggle = () => {
- this.props.onChange(this.props.name);
- }
-
- render() {
- const { active, shortText, longText } = this.props;
- return (
-
-
-
-
-
- {shortText}
- {longText}
-
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/attach_options.js b/app/javascript/flavours/glitch/features/compose/components/attach_options.js
deleted file mode 100644
index 6c7a1f55f68..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/attach_options.js
+++ /dev/null
@@ -1,131 +0,0 @@
-// Package imports //
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { injectIntl, defineMessages } from 'react-intl';
-
-// Our imports //
-import ComposeDropdown from './dropdown';
-import { uploadCompose } from 'flavours/glitch/actions/compose';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { openModal } from 'flavours/glitch/actions/modal';
-
-const messages = defineMessages({
- upload :
- { id: 'compose.attach.upload', defaultMessage: 'Upload a file' },
- doodle :
- { id: 'compose.attach.doodle', defaultMessage: 'Draw something' },
- attach :
- { id: 'compose.attach', defaultMessage: 'Attach...' },
-});
-
-const mapStateToProps = state => ({
- // This horrible expression is copied from vanilla upload_button_container
- disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
- resetFileKey: state.getIn(['compose', 'resetFileKey']),
- acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
-});
-
-const mapDispatchToProps = dispatch => ({
- onSelectFile (files) {
- dispatch(uploadCompose(files));
- },
- onOpenDoodle () {
- dispatch(openModal('DOODLE', { noEsc: true }));
- },
-});
-
-@injectIntl
-@connect(mapStateToProps, mapDispatchToProps)
-export default class ComposeAttachOptions extends ImmutablePureComponent {
-
- static propTypes = {
- intl : PropTypes.object.isRequired,
- resetFileKey: PropTypes.number,
- acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
- disabled: PropTypes.bool,
- onSelectFile: PropTypes.func.isRequired,
- onOpenDoodle: PropTypes.func.isRequired,
- };
-
- handleItemClick = bt => {
- if (bt === 'upload') {
- this.fileElement.click();
- }
-
- if (bt === 'doodle') {
- this.props.onOpenDoodle();
- }
-
- this.dropdown.setState({ open: false });
- };
-
- handleFileChange = (e) => {
- if (e.target.files.length > 0) {
- this.props.onSelectFile(e.target.files);
- }
- }
-
- setFileRef = (c) => {
- this.fileElement = c;
- }
-
- setDropdownRef = (c) => {
- this.dropdown = c;
- }
-
- render () {
- const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
-
- const options = [
- { icon: 'cloud-upload', text: messages.upload, name: 'upload' },
- { icon: 'paint-brush', text: messages.doodle, name: 'doodle' },
- ];
-
- const optionElems = options.map((item) => {
- const hdl = () => this.handleItemClick(item.name);
- return (
-
-
-
-
-
-
- {intl.formatMessage(item.text)}
-
-
- );
- });
-
- return (
-
-
- {optionElems}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
deleted file mode 100644
index 3d474af30e5..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from 'react';
-import Avatar from 'flavours/glitch/components/avatar';
-import DisplayName from 'flavours/glitch/components/display_name';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-export default class AutosuggestAccount extends ImmutablePureComponent {
-
- static propTypes = {
- account: ImmutablePropTypes.map.isRequired,
- };
-
- render () {
- const { account } = this.props;
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js
deleted file mode 100644
index 0ecfc9141d8..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/character_counter.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { length } from 'stringz';
-
-export default class CharacterCounter extends React.PureComponent {
-
- static propTypes = {
- text: PropTypes.string.isRequired,
- max: PropTypes.number.isRequired,
- };
-
- checkRemainingText (diff) {
- if (diff < 0) {
- return {diff} ;
- }
-
- return {diff} ;
- }
-
- render () {
- const diff = this.props.max - length(this.props.text);
- return this.checkRemainingText(diff);
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
deleted file mode 100644
index 67ce935f43c..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ /dev/null
@@ -1,286 +0,0 @@
-import React from 'react';
-import CharacterCounter from './character_counter';
-import Button from 'flavours/glitch/components/button';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import ReplyIndicatorContainer from '../containers/reply_indicator_container';
-import AutosuggestTextarea from 'flavours/glitch/components/autosuggest_textarea';
-import { defineMessages, injectIntl } from 'react-intl';
-import Collapsable from 'flavours/glitch/components/collapsable';
-import SpoilerButtonContainer from '../containers/spoiler_button_container';
-import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
-import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container';
-import SensitiveButtonContainer from '../containers/sensitive_button_container';
-import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
-import UploadFormContainer from '../containers/upload_form_container';
-import WarningContainer from '../containers/warning_container';
-import { isMobile } from 'flavours/glitch/util/is_mobile';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { length } from 'stringz';
-import { countableText } from 'flavours/glitch/util/counter';
-import ComposeAttachOptions from './attach_options';
-import initialState from 'flavours/glitch/util/initial_state';
-
-const maxChars = initialState.max_toot_chars;
-
-const messages = defineMessages({
- placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
- spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
- publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
- publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
-});
-
-@injectIntl
-export default class ComposeForm extends ImmutablePureComponent {
-
- static propTypes = {
- intl: PropTypes.object.isRequired,
- text: PropTypes.string.isRequired,
- suggestion_token: PropTypes.string,
- suggestions: ImmutablePropTypes.list,
- spoiler: PropTypes.bool,
- privacy: PropTypes.string,
- advanced_options: ImmutablePropTypes.contains({
- do_not_federate: PropTypes.bool,
- }),
- spoiler_text: PropTypes.string,
- focusDate: PropTypes.instanceOf(Date),
- preselectDate: PropTypes.instanceOf(Date),
- is_submitting: PropTypes.bool,
- is_uploading: PropTypes.bool,
- onChange: PropTypes.func.isRequired,
- onSubmit: PropTypes.func.isRequired,
- onClearSuggestions: PropTypes.func.isRequired,
- onFetchSuggestions: PropTypes.func.isRequired,
- onPrivacyChange: PropTypes.func.isRequired,
- onSuggestionSelected: PropTypes.func.isRequired,
- onChangeSpoilerText: PropTypes.func.isRequired,
- onPaste: PropTypes.func.isRequired,
- onPickEmoji: PropTypes.func.isRequired,
- showSearch: PropTypes.bool,
- settings : ImmutablePropTypes.map.isRequired,
- };
-
- static defaultProps = {
- showSearch: false,
- };
-
- handleChange = (e) => {
- this.props.onChange(e.target.value);
- }
-
- handleKeyDown = (e) => {
- if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
- this.handleSubmit();
- }
- }
-
- handleSubmit2 = () => {
- this.props.onPrivacyChange(this.props.settings.get('side_arm'));
- this.handleSubmit();
- }
-
- 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);
- }
-
- this.props.onSubmit();
- }
-
- onSuggestionsClearRequested = () => {
- this.props.onClearSuggestions();
- }
-
- onSuggestionsFetchRequested = (token) => {
- this.props.onFetchSuggestions(token);
- }
-
- onSuggestionSelected = (tokenStart, token, value) => {
- this._restoreCaret = null;
- this.props.onSuggestionSelected(tokenStart, token, value);
- }
-
- handleChangeSpoilerText = (e) => {
- this.props.onChangeSpoilerText(e.target.value);
- }
-
- componentWillReceiveProps (nextProps) {
- // If this is the update where we've finished uploading,
- // save the last caret position so we can restore it below!
- if (!nextProps.is_uploading && this.props.is_uploading) {
- this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
- }
- }
-
- componentDidUpdate (prevProps) {
- // This statement does several things:
- // - If we're beginning a reply, and,
- // - Replying to zero or one users, places the cursor at the end of the textbox.
- // - Replying to more than one user, selects any usernames past the first;
- // this provides a convenient shortcut to drop everyone else from the conversation.
- // - If we've just finished uploading an image, and have a saved caret position,
- // restores the cursor to that position after the text changes!
- if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
- let selectionEnd, selectionStart;
-
- if (this.props.preselectDate !== prevProps.preselectDate) {
- selectionEnd = this.props.text.length;
- selectionStart = this.props.text.search(/\s/) + 1;
- } else if (typeof this._restoreCaret === 'number') {
- selectionStart = this._restoreCaret;
- selectionEnd = this._restoreCaret;
- } else {
- selectionEnd = this.props.text.length;
- selectionStart = selectionEnd;
- }
-
- this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
- this.autosuggestTextarea.textarea.focus();
- } else if(prevProps.is_submitting && !this.props.is_submitting) {
- this.autosuggestTextarea.textarea.focus();
- }
- }
-
- setAutosuggestTextarea = (c) => {
- this.autosuggestTextarea = c;
- }
-
- handleEmojiPick = (data) => {
- const position = this.autosuggestTextarea.textarea.selectionStart;
- const emojiChar = data.native;
- this._restoreCaret = position + emojiChar.length + 1;
- this.props.onPickEmoji(position, data);
- }
-
- render () {
- const { intl, onPaste, showSearch } = this.props;
- const disabled = this.props.is_submitting;
- const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
- const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
-
- const secondaryVisibility = this.props.settings.get('side_arm');
- let showSideArm = secondaryVisibility !== 'none';
-
- let publishText = '';
- let publishText2 = '';
- let title = '';
- let title2 = '';
-
- const privacyIcons = {
- none: '',
- public: 'globe',
- unlisted: 'unlock-alt',
- private: 'lock',
- direct: 'envelope',
- };
-
- title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
-
- if (showSideArm) {
- // Enhanced behavior with dual toot buttons
- publishText = (
-
- {
-
- }{intl.formatMessage(messages.publish)}
-
- );
-
- title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
- publishText2 = (
-
- );
- } else {
- // Original vanilla behavior - no icon if public or unlisted
- if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
- publishText = {intl.formatMessage(messages.publish)} ;
- } else {
- publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
- }
- }
-
- const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0);
-
- return (
-
-
-
-
- {intl.formatMessage(messages.spoiler_placeholder)}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- showSideArm ?
- : ''
- }
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
deleted file mode 100644
index 1b0000fb7f9..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js
+++ /dev/null
@@ -1,77 +0,0 @@
-// Package imports.
-import React from 'react';
-import PropTypes from 'prop-types';
-
-// Our imports.
-import IconButton from 'flavours/glitch/components/icon_button';
-
-const iconStyle = {
- height : null,
- lineHeight : '27px',
-};
-
-export default class ComposeDropdown extends React.PureComponent {
-
- static propTypes = {
- title: PropTypes.string.isRequired,
- icon: PropTypes.string,
- highlight: PropTypes.bool,
- disabled: PropTypes.bool,
- children: PropTypes.arrayOf(PropTypes.node).isRequired,
- };
-
- state = {
- open: false,
- };
-
- onGlobalClick = (e) => {
- if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
- this.setState({ open: false });
- }
- };
-
- componentDidMount () {
- window.addEventListener('click', this.onGlobalClick);
- window.addEventListener('touchstart', this.onGlobalClick);
- }
- componentWillUnmount () {
- window.removeEventListener('click', this.onGlobalClick);
- window.removeEventListener('touchstart', this.onGlobalClick);
- }
-
- onToggleDropdown = () => {
- if (this.props.disabled) return;
- this.setState({ open: !this.state.open });
- };
-
- setRef = (c) => {
- this.node = c;
- };
-
- render () {
- const { open } = this.state;
- let { highlight, title, icon, disabled } = this.props;
-
- if (!icon) icon = 'ellipsis-h';
-
- return (
-
-
-
-
-
- {this.props.children}
-
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js
deleted file mode 100644
index 90f062f8f08..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js
+++ /dev/null
@@ -1,200 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, defineMessages } from 'react-intl';
-import IconButton from 'flavours/glitch/components/icon_button';
-import Overlay from 'react-overlays/lib/Overlay';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import detectPassiveEvents from 'detect-passive-events';
-import classNames from 'classnames';
-
-const messages = defineMessages({
- public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
- public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
- unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
- unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
- private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
- private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
- direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
- direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
- change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
-});
-
-const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
-
-class PrivacyDropdownMenu extends React.PureComponent {
-
- static propTypes = {
- style: PropTypes.object,
- items: PropTypes.array.isRequired,
- value: PropTypes.string.isRequired,
- onClose: PropTypes.func.isRequired,
- onChange: PropTypes.func.isRequired,
- };
-
- handleDocumentClick = e => {
- if (this.node && !this.node.contains(e.target)) {
- this.props.onClose();
- }
- }
-
- handleClick = e => {
- if (e.key === 'Escape') {
- this.props.onClose();
- } else if (!e.key || e.key === 'Enter') {
- const value = e.currentTarget.getAttribute('data-index');
-
- e.preventDefault();
-
- this.props.onClose();
- this.props.onChange(value);
- }
- }
-
- 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;
- }
-
- render () {
- const { style, items, value } = this.props;
-
- return (
-
- {({ opacity, scaleX, scaleY }) => (
-
- {items.map(item =>
-
-
-
-
-
-
- {item.text}
- {item.meta}
-
-
- )}
-
- )}
-
- );
- }
-
-}
-
-@injectIntl
-export default class PrivacyDropdown extends React.PureComponent {
-
- static propTypes = {
- isUserTouching: PropTypes.func,
- isModalOpen: PropTypes.bool.isRequired,
- onModalOpen: PropTypes.func,
- onModalClose: PropTypes.func,
- value: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- state = {
- open: false,
- };
-
- handleToggle = () => {
- if (this.props.isUserTouching()) {
- if (this.state.open) {
- this.props.onModalClose();
- } else {
- this.props.onModalOpen({
- actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
- onClick: this.handleModalActionClick,
- });
- }
- } else {
- this.setState({ open: !this.state.open });
- }
- }
-
- handleModalActionClick = (e) => {
- e.preventDefault();
-
- const { value } = this.options[e.currentTarget.getAttribute('data-index')];
-
- this.props.onModalClose();
- this.props.onChange(value);
- }
-
- handleKeyDown = e => {
- switch(e.key) {
- case 'Enter':
- this.handleToggle();
- break;
- case 'Escape':
- this.handleClose();
- break;
- }
- }
-
- handleClose = () => {
- this.setState({ open: false });
- }
-
- handleChange = value => {
- this.props.onChange(value);
- }
-
- componentWillMount () {
- const { intl: { formatMessage } } = this.props;
-
- this.options = [
- { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
- { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
- { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
- { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
- ];
- }
-
- render () {
- const { value, intl } = this.props;
- const { open } = this.state;
-
- const valueOption = this.options.find(item => item.value === value);
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
deleted file mode 100644
index 3048d591b2b..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Avatar from 'flavours/glitch/components/avatar';
-import IconButton from 'flavours/glitch/components/icon_button';
-import DisplayName from 'flavours/glitch/components/display_name';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { isRtl } from 'flavours/glitch/util/rtl';
-
-const messages = defineMessages({
- cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
-});
-
-@injectIntl
-export default class ReplyIndicator extends ImmutablePureComponent {
-
- static contextTypes = {
- router: PropTypes.object,
- };
-
- static propTypes = {
- status: ImmutablePropTypes.map,
- onCancel: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleClick = () => {
- this.props.onCancel();
- }
-
- handleAccountClick = (e) => {
- if (e.button === 0) {
- e.preventDefault();
- this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
- }
- }
-
- render () {
- const { status, intl } = this.props;
-
- if (!status) {
- return null;
- }
-
- const content = { __html: status.get('contentHtml') };
- const style = {
- direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr',
- };
-
- return (
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
deleted file mode 100644
index a1fc93234c1..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import IconButton from 'flavours/glitch/components/icon_button';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl } from 'react-intl';
-import classNames from 'classnames';
-
-const messages = defineMessages({
- undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
- description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
-});
-
-@injectIntl
-export default class Upload extends ImmutablePureComponent {
-
- static propTypes = {
- media: ImmutablePropTypes.map.isRequired,
- intl: PropTypes.object.isRequired,
- onUndo: PropTypes.func.isRequired,
- onDescriptionChange: PropTypes.func.isRequired,
- };
-
- state = {
- hovered: false,
- focused: false,
- dirtyDescription: null,
- };
-
- handleUndoClick = () => {
- this.props.onUndo(this.props.media.get('id'));
- }
-
- handleInputChange = e => {
- this.setState({ dirtyDescription: e.target.value });
- }
-
- handleMouseEnter = () => {
- this.setState({ hovered: true });
- }
-
- handleMouseLeave = () => {
- this.setState({ hovered: false });
- }
-
- handleInputFocus = () => {
- this.setState({ focused: true });
- }
-
- handleInputBlur = () => {
- const { dirtyDescription } = this.state;
-
- this.setState({ focused: false, dirtyDescription: null });
-
- if (dirtyDescription !== null) {
- this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
- }
- }
-
- render () {
- const { intl, media } = this.props;
- const active = this.state.hovered || this.state.focused;
- const description = this.state.dirtyDescription || media.get('description') || '';
-
- return (
-
-
- {({ scale }) => (
-
-
-
-
-
- {intl.formatMessage(messages.description)}
-
-
-
-
-
- )}
-
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_button.js b/app/javascript/flavours/glitch/features/compose/components/upload_button.js
deleted file mode 100644
index f06167a2a98..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload_button.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import React from 'react';
-import IconButton from 'flavours/glitch/components/icon_button';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-
-const messages = defineMessages({
- upload: { id: 'upload_button.label', defaultMessage: 'Add media' },
-});
-
-const makeMapStateToProps = () => {
- const mapStateToProps = state => ({
- acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
- });
-
- return mapStateToProps;
-};
-
-const iconStyle = {
- height: null,
- lineHeight: '27px',
-};
-
-@connect(makeMapStateToProps)
-@injectIntl
-export default class UploadButton extends ImmutablePureComponent {
-
- static propTypes = {
- disabled: PropTypes.bool,
- onSelectFile: PropTypes.func.isRequired,
- style: PropTypes.object,
- resetFileKey: PropTypes.number,
- acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- handleChange = (e) => {
- if (e.target.files.length > 0) {
- this.props.onSelectFile(e.target.files);
- }
- }
-
- handleClick = () => {
- this.fileElement.click();
- }
-
- setRef = (c) => {
- this.fileElement = c;
- }
-
- render () {
-
- const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
-
- return (
-
-
-
- {intl.formatMessage(messages.upload)}
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
deleted file mode 100644
index b7f11220530..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import UploadProgressContainer from '../containers/upload_progress_container';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import UploadContainer from '../containers/upload_container';
-
-export default class UploadForm extends ImmutablePureComponent {
-
- static propTypes = {
- mediaIds: ImmutablePropTypes.list.isRequired,
- };
-
- render () {
- const { mediaIds } = this.props;
-
- return (
-
-
-
-
- {mediaIds.map(id => (
-
- ))}
-
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
deleted file mode 100644
index 2a3b8ceb481..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { FormattedMessage } from 'react-intl';
-
-export default class UploadProgress extends React.PureComponent {
-
- static propTypes = {
- active: PropTypes.bool,
- progress: PropTypes.number,
- };
-
- render () {
- const { active, progress } = this.props;
-
- if (!active) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
- {({ width }) =>
-
- }
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/warning.js b/app/javascript/flavours/glitch/features/compose/components/warning.js
deleted file mode 100644
index 4962e76c8ad..00000000000
--- a/app/javascript/flavours/glitch/features/compose/components/warning.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-
-export default class Warning extends React.PureComponent {
-
- static propTypes = {
- message: PropTypes.node.isRequired,
- };
-
- render () {
- const { message } = this.props;
-
- return (
-
- {({ opacity, scaleX, scaleY }) => (
-
- {message}
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js b/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js
deleted file mode 100644
index da381568b2b..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js
+++ /dev/null
@@ -1,20 +0,0 @@
-// Package imports.
-import { connect } from 'react-redux';
-
-// Our imports.
-import { toggleComposeAdvancedOption } from 'flavours/glitch/actions/compose';
-import ComposeAdvancedOptions from '../components/advanced_options';
-
-const mapStateToProps = state => ({
- values: state.getIn(['compose', 'advanced_options']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onChange (option) {
- dispatch(toggleComposeAdvancedOption(option));
- },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
deleted file mode 100644
index 0e1c328fe90..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { connect } from 'react-redux';
-import AutosuggestAccount from '../components/autosuggest_account';
-import { makeGetAccount } from 'flavours/glitch/selectors';
-
-const makeMapStateToProps = () => {
- const getAccount = makeGetAccount();
-
- const mapStateToProps = (state, { id }) => ({
- account: getAccount(state, id),
- });
-
- return mapStateToProps;
-};
-
-export default connect(makeMapStateToProps)(AutosuggestAccount);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
deleted file mode 100644
index e2e93e44b61..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { connect } from 'react-redux';
-import ComposeForm from '../components/compose_form';
-import { changeComposeVisibility, uploadCompose } from 'flavours/glitch/actions/compose';
-import {
- changeCompose,
- submitCompose,
- clearComposeSuggestions,
- fetchComposeSuggestions,
- selectComposeSuggestion,
- changeComposeSpoilerText,
- insertEmojiCompose,
-} from 'flavours/glitch/actions/compose';
-
-const mapStateToProps = state => ({
- text: state.getIn(['compose', 'text']),
- suggestion_token: state.getIn(['compose', 'suggestion_token']),
- suggestions: state.getIn(['compose', 'suggestions']),
- advanced_options: state.getIn(['compose', 'advanced_options']),
- spoiler: state.getIn(['compose', 'spoiler']),
- spoiler_text: state.getIn(['compose', 'spoiler_text']),
- privacy: state.getIn(['compose', 'privacy']),
- focusDate: state.getIn(['compose', 'focusDate']),
- preselectDate: state.getIn(['compose', 'preselectDate']),
- is_submitting: state.getIn(['compose', 'is_submitting']),
- is_uploading: state.getIn(['compose', 'is_uploading']),
- showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
- settings: state.get('local_settings'),
- filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,
-});
-
-const mapDispatchToProps = (dispatch) => ({
-
- onChange (text) {
- dispatch(changeCompose(text));
- },
-
- onPrivacyChange (value) {
- dispatch(changeComposeVisibility(value));
- },
-
- onSubmit () {
- dispatch(submitCompose());
- },
-
- onClearSuggestions () {
- dispatch(clearComposeSuggestions());
- },
-
- onFetchSuggestions (token) {
- dispatch(fetchComposeSuggestions(token));
- },
-
- onSuggestionSelected (position, token, accountId) {
- dispatch(selectComposeSuggestion(position, token, accountId));
- },
-
- onChangeSpoilerText (checked) {
- dispatch(changeComposeSpoilerText(checked));
- },
-
- onPaste (files) {
- dispatch(uploadCompose(files));
- },
-
- onPickEmoji (position, data) {
- dispatch(insertEmojiCompose(position, data));
- },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
deleted file mode 100644
index ba85edd8737..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import { connect } from 'react-redux';
-import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
-import { changeSetting } from 'flavours/glitch/actions/settings';
-import { createSelector } from 'reselect';
-import { Map as ImmutableMap } from 'immutable';
-import { useEmoji } from 'flavours/glitch/actions/emojis';
-
-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) {
- emojis = emojis.concat(DEFAULTS.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: state.getIn(['settings', 'skinTone']),
- frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
-});
-
-const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
- onSkinTone: skinTone => {
- dispatch(changeSetting(['skinTone'], skinTone));
- },
-
- onPickEmoji: emoji => {
- dispatch(useEmoji(emoji));
-
- if (onPickEmoji) {
- onPickEmoji(emoji);
- }
- },
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
deleted file mode 100644
index cb94fcc8064..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { connect } from 'react-redux';
-import PrivacyDropdown from '../components/privacy_dropdown';
-import { changeComposeVisibility } from 'flavours/glitch/actions/compose';
-import { openModal, closeModal } from 'flavours/glitch/actions/modal';
-import { isUserTouching } from 'flavours/glitch/util/is_mobile';
-
-const mapStateToProps = state => ({
- isModalOpen: state.get('modal').modalType === 'ACTIONS',
- value: state.getIn(['compose', 'privacy']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onChange (value) {
- dispatch(changeComposeVisibility(value));
- },
-
- isUserTouching,
- onModalOpen: props => dispatch(openModal('ACTIONS', props)),
- onModalClose: () => dispatch(closeModal()),
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
deleted file mode 100644
index a7c82d135dc..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { connect } from 'react-redux';
-import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
-import { makeGetStatus } from 'flavours/glitch/selectors';
-import ReplyIndicator from '../components/reply_indicator';
-
-const makeMapStateToProps = () => {
- const getStatus = makeGetStatus();
-
- const mapStateToProps = state => ({
- status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
- });
-
- return mapStateToProps;
-};
-
-const mapDispatchToProps = dispatch => ({
-
- onCancel () {
- dispatch(cancelReplyCompose());
- },
-
-});
-
-export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
deleted file mode 100644
index cf6706c0e0a..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import IconButton from 'flavours/glitch/components/icon_button';
-import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { injectIntl, defineMessages } from 'react-intl';
-
-const messages = defineMessages({
- title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' },
-});
-
-const mapStateToProps = state => ({
- visible: state.getIn(['compose', 'media_attachments']).size > 0,
- active: state.getIn(['compose', 'sensitive']),
- disabled: state.getIn(['compose', 'spoiler']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onClick () {
- dispatch(changeComposeSensitivity());
- },
-
-});
-
-class SensitiveButton extends React.PureComponent {
-
- static propTypes = {
- visible: PropTypes.bool,
- active: PropTypes.bool,
- disabled: PropTypes.bool,
- onClick: PropTypes.func.isRequired,
- intl: PropTypes.object.isRequired,
- };
-
- render () {
- const { visible, active, disabled, onClick, intl } = this.props;
-
- return (
-
- {({ scale }) => {
- const icon = active ? 'eye-slash' : 'eye';
- const className = classNames('compose-form__sensitive-button', {
- 'compose-form__sensitive-button--visible': visible,
- });
- return (
-
-
-
- );
- }}
-
- );
- }
-
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js
deleted file mode 100644
index d7b4246bc2f..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { connect } from 'react-redux';
-import TextIconButton from '../components/text_icon_button';
-import { changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
-import { injectIntl, defineMessages } from 'react-intl';
-
-const messages = defineMessages({
- title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' },
-});
-
-const mapStateToProps = (state, { intl }) => ({
- label: 'CW',
- title: intl.formatMessage(messages.title),
- active: state.getIn(['compose', 'spoiler']),
- ariaControls: 'cw-spoiler-input',
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onClick () {
- dispatch(changeComposeSpoilerness());
- },
-
-});
-
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js
deleted file mode 100644
index 4c1cb49e938..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { connect } from 'react-redux';
-import UploadButton from '../components/upload_button';
-import { uploadCompose } from 'flavours/glitch/actions/compose';
-
-const mapStateToProps = state => ({
- disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
- resetFileKey: state.getIn(['compose', 'resetFileKey']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onSelectFile (files) {
- dispatch(uploadCompose(files));
- },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
deleted file mode 100644
index 368038425e1..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { connect } from 'react-redux';
-import Upload from '../components/upload';
-import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose';
-
-const mapStateToProps = (state, { id }) => ({
- media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
-});
-
-const mapDispatchToProps = dispatch => ({
-
- onUndo: id => {
- dispatch(undoUploadCompose(id));
- },
-
- onDescriptionChange: (id, description) => {
- dispatch(changeUploadCompose(id, description));
- },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
deleted file mode 100644
index a6798bf512c..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { connect } from 'react-redux';
-import UploadForm from '../components/upload_form';
-
-const mapStateToProps = state => ({
- mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
-});
-
-export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
deleted file mode 100644
index 0cfee96daaa..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
+++ /dev/null
@@ -1,9 +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);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
deleted file mode 100644
index f20c75b9115..00000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import Warning from '../components/warning';
-import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
-import { me } from 'flavours/glitch/util/initial_state';
-
-const mapStateToProps = state => ({
- needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
-});
-
-const WarningWrapper = ({ needsLockWarning }) => {
- if (needsLockWarning) {
- return }} />} />;
- }
-
- return null;
-};
-
-WarningWrapper.propTypes = {
- needsLockWarning: PropTypes.bool,
-};
-
-export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/flavours/glitch/features/compose/index.js b/app/javascript/flavours/glitch/features/compose/index.js
deleted file mode 100644
index 63c9500b167..00000000000
--- a/app/javascript/flavours/glitch/features/compose/index.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import React from 'react';
-import ComposeFormContainer from './containers/compose_form_container';
-import NavigationContainer from './containers/navigation_container';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
-import { openModal } from 'flavours/glitch/actions/modal';
-import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
-import { Link } from 'react-router-dom';
-import { injectIntl, defineMessages } from 'react-intl';
-import SearchContainer from './containers/search_container';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import SearchResultsContainer from './containers/search_results_container';
-import { changeComposing } from 'flavours/glitch/actions/compose';
-
-const messages = defineMessages({
- start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
- home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
- notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
- public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
- community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
- settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
- logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
-});
-
-const mapStateToProps = state => ({
- columns: state.getIn(['settings', 'columns']),
- showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class Compose extends React.PureComponent {
-
- static propTypes = {
- dispatch: PropTypes.func.isRequired,
- columns: ImmutablePropTypes.list.isRequired,
- multiColumn: PropTypes.bool,
- showSearch: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- };
-
- componentDidMount () {
- this.props.dispatch(mountCompose());
- }
-
- componentWillUnmount () {
- this.props.dispatch(unmountCompose());
- }
-
- onLayoutClick = (e) => {
- const layout = e.currentTarget.getAttribute('data-mastodon-layout');
- this.props.dispatch(changeLocalSetting(['layout'], layout));
- e.preventDefault();
- }
-
- openSettings = () => {
- this.props.dispatch(openModal('SETTINGS', {}));
- }
-
- onFocus = () => {
- this.props.dispatch(changeComposing(true));
- }
-
- onBlur = () => {
- this.props.dispatch(changeComposing(false));
- }
-
- render () {
- const { multiColumn, showSearch, intl } = this.props;
-
- let header = '';
-
- if (multiColumn) {
- const { columns } = this.props;
- header = (
-
-
- {!columns.some(column => column.get('id') === 'HOME') && (
-
- )}
- {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
-
- )}
- {!columns.some(column => column.get('id') === 'COMMUNITY') && (
-
- )}
- {!columns.some(column => column.get('id') === 'PUBLIC') && (
-
- )}
-
-
-
- );
- }
-
-
-
- return (
-
- {header}
-
-
-
-
-
-
-
-
-
-
- {({ x }) =>
-
-
-
- }
-
-
-
-
- );
- }
-
-}
diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js
new file mode 100644
index 00000000000..25c2622d868
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/index.js
@@ -0,0 +1,440 @@
+// Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router';
+
+// Actions.
+import {
+ cancelReplyCompose,
+ changeCompose,
+ changeComposeSensitivity,
+ changeComposeSpoilerText,
+ changeComposeSpoilerness,
+ changeComposeVisibility,
+ changeUploadCompose,
+ clearComposeSuggestions,
+ fetchComposeSuggestions,
+ insertEmojiCompose,
+ selectComposeSuggestion,
+ submitCompose,
+ toggleComposeAdvancedOption,
+ undoUploadCompose,
+ uploadCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+ closeModal,
+ openModal,
+} from 'flavours/glitch/actions/modal';
+
+// Components.
+import ComposerOptions from './options';
+import ComposerPublisher from './publisher';
+import ComposerReply from './reply';
+import ComposerSpoiler from './spoiler';
+import ComposerTextarea from './textarea';
+import ComposerUploadForm from './upload_form';
+import ComposerWarning from './warning';
+
+// Utils.
+import { countableText } from 'flavours/glitch/util/counter';
+import { me } from 'flavours/glitch/util/initial_state';
+import { isMobile } from 'flavours/glitch/util/is_mobile';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { mergeProps } from 'flavours/glitch/util/redux_helpers';
+
+// State mapping.
+function mapStateToProps (state) {
+ const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+ return {
+ acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
+ amUnlocked: !state.getIn(['accounts', me, 'locked']),
+ doNotFederate: state.getIn(['compose', 'advanced_options', 'do_not_federate']),
+ focusDate: state.getIn(['compose', 'focusDate']),
+ isSubmitting: state.getIn(['compose', 'is_submitting']),
+ isUploading: state.getIn(['compose', 'is_uploading']),
+ media: state.getIn(['compose', 'media_attachments']),
+ preselectDate: state.getIn(['compose', 'preselectDate']),
+ privacy: state.getIn(['compose', 'privacy']),
+ progress: state.getIn(['compose', 'progress']),
+ replyAccount: inReplyTo ? state.getIn(['accounts', state.getIn(['statuses', inReplyTo, 'account'])]) : null,
+ replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
+ resetFileKey: state.getIn(['compose', 'resetFileKey']),
+ sideArm: state.getIn(['local_settings', 'side_arm']),
+ sensitive: state.getIn(['compose', 'sensitive']),
+ showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+ spoiler: state.getIn(['compose', 'spoiler']),
+ spoilerText: state.getIn(['compose', 'spoiler_text']),
+ suggestionToken: state.getIn(['compose', 'suggestion_token']),
+ suggestions: state.getIn(['compose', 'suggestions']),
+ text: state.getIn(['compose', 'text']),
+ };
+};
+
+// Dispatch mapping.
+const mapDispatchToProps = dispatch => ({
+ cancelReply () {
+ dispatch(cancelReplyCompose());
+ },
+ changeDescription (mediaId, description) {
+ dispatch(changeUploadCompose(mediaId, description));
+ },
+ changeSensitivity () {
+ dispatch(changeComposeSensitivity());
+ },
+ changeSpoilerText (checked) {
+ dispatch(changeComposeSpoilerText(checked));
+ },
+ changeSpoilerness () {
+ dispatch(changeComposeSpoilerness());
+ },
+ changeText (text) {
+ dispatch(changeCompose(text));
+ },
+ changeVisibility (value) {
+ dispatch(changeComposeVisibility(value));
+ },
+ clearSuggestions () {
+ dispatch(clearComposeSuggestions());
+ },
+ closeModal () {
+ dispatch(closeModal());
+ },
+ fetchSuggestions (token) {
+ dispatch(fetchComposeSuggestions(token));
+ },
+ insertEmoji (position, data) {
+ dispatch(insertEmojiCompose(position, data));
+ },
+ openActionsModal (data) {
+ dispatch(openModal('ACTIONS', data));
+ },
+ openDoodleModal () {
+ dispatch(openModal('DOODLE', { noEsc: true }));
+ },
+ selectSuggestion (position, token, accountId) {
+ dispatch(selectComposeSuggestion(position, token, accountId));
+ },
+ submit () {
+ dispatch(submitCompose());
+ },
+ toggleAdvancedOption (option) {
+ dispatch(toggleComposeAdvancedOption(option));
+ },
+ undoUpload (mediaId) {
+ dispatch(undoUploadCompose(mediaId));
+ },
+ upload (files) {
+ dispatch(uploadCompose(files));
+ },
+});
+
+// Handlers.
+const handlers = {
+
+ // Changes the text value of the spoiler.
+ changeSpoiler ({ target: { value } }) {
+ const { dispatch: { changeSpoilerText } } = this.props;
+ if (changeSpoilerText) {
+ changeSpoilerText(value);
+ }
+ },
+
+ // Inserts an emoji at the caret.
+ emoji (data) {
+ const { textarea: { selectionStart } } = this;
+ const { dispatch: { insertEmoji } } = this.props;
+ this.caretPos = selectionStart + data.native.length + 1;
+ if (insertEmoji) {
+ insertEmoji(selectionStart, data);
+ }
+ },
+
+ // Handles the secondary submit button.
+ secondarySubmit () {
+ const { submit } = this.handlers;
+ const {
+ dispatch: { changeVisibility },
+ side_arm,
+ } = this.props;
+ if (changeVisibility) {
+ changeVisibility(side_arm);
+ }
+ submit();
+ },
+
+ // Selects a suggestion from the autofill.
+ select (tokenStart, token, value) {
+ const { dispatch: { selectSuggestion } } = this.props;
+ this.caretPos = null;
+ if (selectSuggestion) {
+ selectSuggestion(tokenStart, token, value);
+ }
+ },
+
+ // Submits the status.
+ submit () {
+ const { textarea: { value } } = this;
+ const {
+ dispatch: {
+ changeText,
+ submit,
+ },
+ state: { text },
+ } = this.props;
+
+ // If something changes inside the textarea, then we update the
+ // state before submitting.
+ if (changeText && text !== value) {
+ changeText(value);
+ }
+
+ // Submits the status.
+ if (submit) {
+ submit();
+ }
+ },
+
+ // Sets a reference to the textarea.
+ refTextarea ({ textarea }) {
+ this.textarea = textarea;
+ },
+};
+
+// The component.
+@injectIntl
+@connect(mapStateToProps, mapDispatchToProps, mergeProps)
+export default class Composer extends React.Component {
+
+ // Constructor.
+ constructor (props) {
+ super(props);
+ assignHandlers(this, handlers);
+
+ // Instance variables.
+ this.caretPos = null;
+ this.textarea = null;
+ }
+
+ // If this is the update where we've finished uploading,
+ // save the last caret position so we can restore it below!
+ componentWillReceiveProps (nextProps) {
+ const { textarea: { selectionStart } } = this;
+ const { state: { isUploading } } = this.props;
+ if (isUploading && !nextProps.state.isUploading) {
+ this.caretPos = selectionStart;
+ }
+ }
+
+ // This statement does several things:
+ // - If we're beginning a reply, and,
+ // - Replying to zero or one users, places the cursor at the end
+ // of the textbox.
+ // - Replying to more than one user, selects any usernames past
+ // the first; this provides a convenient shortcut to drop
+ // everyone else from the conversation.
+ // - If we've just finished uploading an image, and have a saved
+ // caret position, restores the cursor to that position after the
+ // text changes.
+ componentDidUpdate (prevProps) {
+ const {
+ caretPos,
+ textarea,
+ } = this;
+ const {
+ state: {
+ focusDate,
+ isUploading,
+ isSubmitting,
+ preselectDate,
+ text,
+ },
+ } = this.props;
+ let selectionEnd, selectionStart;
+
+ // Caret/selection handling.
+ if (focusDate !== prevProps.state.focusDate || (prevProps.state.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
+ switch (true) {
+ case preselectDate !== prevProps.state.preselectDate:
+ selectionStart = text.search(/\s/) + 1;
+ selectionEnd = text.length;
+ break;
+ case !isNaN(caretPos) && caretPos !== null:
+ selectionStart = selectionEnd = caretPos;
+ break;
+ default:
+ selectionStart = selectionEnd = text.length;
+ }
+ textarea.setSelectionRange(selectionStart, selectionEnd);
+ textarea.focus();
+
+ // Refocuses the textarea after submitting.
+ } else if (prevProps.state.isSubmitting && !isSubmitting) {
+ textarea.focus();
+ }
+ }
+
+ render () {
+ const {
+ changeSpoiler,
+ emoji,
+ secondarySubmit,
+ select,
+ submit,
+ refTextarea,
+ } = this.handlers;
+ const { history } = this.context;
+ const {
+ dispatch: {
+ cancelReply,
+ changeDescription,
+ changeSensitivity,
+ changeText,
+ changeVisibility,
+ clearSuggestions,
+ closeModal,
+ fetchSuggestions,
+ openActionsModal,
+ openDoodleModal,
+ toggleAdvancedOption,
+ undoUpload,
+ upload,
+ },
+ intl,
+ state: {
+ acceptContentTypes,
+ amUnlocked,
+ doNotFederate,
+ isSubmitting,
+ isUploading,
+ media,
+ privacy,
+ progress,
+ replyAccount,
+ replyContent,
+ resetFileKey,
+ sensitive,
+ showSearch,
+ sideArm,
+ spoiler,
+ spoilerText,
+ suggestions,
+ text,
+ },
+ } = this.props;
+
+ return (
+
+
+ {privacy === 'private' && amUnlocked ? : null}
+ {replyContent ? (
+
+ ) : null}
+
+ {media && media.size ? (
+
+ ) : null}
+ = 4 || media.some(
+ item => item.get('type') === 'video'
+ )}
+ hasMedia={!!media.size}
+ intl={intl}
+ onChangeSensitivity={changeSensitivity}
+ onChangeVisibility={changeVisibility}
+ onDoodleOpen={openDoodleModal}
+ onModalClose={closeModal}
+ onModalOpen={openActionsModal}
+ onToggleAdvancedOption={toggleAdvancedOption}
+ onUpload={upload}
+ privacy={privacy}
+ resetFileKey={resetFileKey}
+ sensitive={sensitive}
+ spoiler={spoiler}
+ />
+
+
+ );
+ }
+
+}
+
+// Context
+Composer.contextTypes = {
+ history: PropTypes.object,
+}
+
+// Props.
+Composer.propTypes = {
+ dispatch: PropTypes.objectOf(PropTypes.func).isRequired,
+ intl: PropTypes.object.isRequired,
+ state: PropTypes.shape({
+ acceptContentTypes: PropTypes.string,
+ amUnlocked: PropTypes.bool,
+ doNotFederate: PropTypes.bool,
+ focusDate: PropTypes.instanceOf(Date),
+ isSubmitting: PropTypes.bool,
+ isUploading: PropTypes.bool,
+ media: PropTypes.list,
+ preselectDate: PropTypes.instanceOf(Date),
+ privacy: PropTypes.string,
+ progress: PropTypes.number,
+ replyAccount: ImmutablePropTypes.map,
+ replyContent: PropTypes.string,
+ resetFileKey: PropTypes.string,
+ sideArm: PropTypes.string,
+ sensitive: PropTypes.bool,
+ showSearch: PropTypes.bool,
+ spoiler: PropTypes.bool,
+ spoilerText: PropTypes.string,
+ suggestionToken: PropTypes.string,
+ suggestions: ImmutablePropTypes.list,
+ text: PropTypes.string,
+ }).isRequired,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
new file mode 100644
index 00000000000..0f304bc88e1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
@@ -0,0 +1,243 @@
+// Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import spring from 'react-motion/lib/spring';
+import Overlay from 'react-overlays/lib/Overlay';
+
+// Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import ComposerOptionsDropdownItem from './item';
+
+// Utils.
+import { withPassive } from 'flavours/glitch/util/dom_helpers';
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+// We'll use this to define our various transitions.
+const springMotion = spring(1, {
+ damping: 35,
+ stiffness: 400,
+});
+
+// Handlers.
+const handlers = {
+
+ // Closes the dropdown.
+ close () {
+ this.setState({ open: false });
+ },
+
+ // When the document is clicked elsewhere, we close the dropdown.
+ documentClick ({ target }) {
+ const { node } = this;
+ const { onClose } = this.props;
+ if (onClose && node && !node.contains(target)) {
+ onClose();
+ }
+ },
+
+ // The enter key toggles the dropdown's open state, and the escape
+ // key closes it.
+ keyDown ({ key }) {
+ const {
+ close,
+ toggle,
+ } = this.handlers;
+ switch (key) {
+ case 'Enter':
+ toggle();
+ break;
+ case 'Escape':
+ close();
+ break;
+ }
+ },
+
+ // Toggles opening and closing the dropdown.
+ toggle () {
+ const {
+ items,
+ onChange,
+ onModalClose,
+ onModalOpen,
+ value,
+ } = this.props;
+ const { open } = this.state;
+
+ // If this is a touch device, we open a modal instead of the
+ // dropdown.
+ if (onModalClose && isUserTouching()) {
+ if (open) {
+ onModalClose()
+ } else if (onChange && onModalOpen) {
+ onModalOpen({
+ actions: items.map(
+ ({
+ name,
+ ...rest
+ }) => ({
+ ...rest,
+ active: value && name === value,
+ onClick (e) {
+ e.preventDefault(); // Prevents focus from changing
+ onModalClose();
+ onChange(name);
+ },
+ })
+ ),
+ });
+ }
+
+ // Otherwise, we just set our state to open.
+ } else {
+ this.setState({ open: !open });
+ }
+ },
+
+ // Stores our node in `this.node`.
+ ref (node) {
+ this.node = node;
+ },
+};
+
+// The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
+
+ // Constructor.
+ constructor (props) {
+ super(props);
+ assignHandlers(this, handlers);
+ this.state = { open: false };
+
+ // Instance variables.
+ this.node = null;
+ }
+
+ // On mounting, we add our listeners.
+ componentDidMount () {
+ const { documentClick } = this.handlers;
+ document.addEventListener('click', documentClick, false);
+ document.addEventListener('touchend', documentClick, withPassive);
+ }
+
+ // On unmounting, we remove our listeners.
+ componentWillUnmount () {
+ const { documentClick } = this.handlers;
+ document.removeEventListener('click', documentClick, false);
+ document.removeEventListener('touchend', documentClick, withPassive);
+ }
+
+ // Rendering.
+ render () {
+ const {
+ close,
+ keyDown,
+ ref,
+ toggle,
+ } = this.handlers;
+ const {
+ active,
+ disabled,
+ title,
+ icon,
+ items,
+ onChange,
+ value,
+ } = this.props;
+ const { open } = this.state;
+ const computedClass = classNames('composer--options--dropdown', {
+ active,
+ open: open || active,
+ });
+
+ // The result.
+ return (
+
+
+
+
+ {({ opacity, scaleX, scaleY }) => (
+
+ {items.map(
+ ({
+ name,
+ ...rest
+ }) => (
+
+ )
+ )}
+
+ )}
+
+
+
+ );
+ }
+
+}
+
+// Props.
+ComposerOptionsDropdown.propTypes = {
+ active: PropTypes.bool,
+ disabled: PropTypes.bool,
+ icon: PropTypes.string,
+ items: PropTypes.arrayOf(PropTypes.shape({
+ icon: PropTypes.string,
+ meta: PropTypes.node,
+ name: PropTypes.string.isRequired,
+ on: PropTypes.bool,
+ text: PropTypes.node,
+ })).isRequired,
+ onChange: PropTypes.func,
+ onModalClose: PropTypes.func,
+ onModalOpen: PropTypes.func,
+ title: PropTypes.string,
+ value: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js
new file mode 100644
index 00000000000..ca4ee393e14
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js
@@ -0,0 +1,126 @@
+// Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Toggle from 'react-toggle';
+
+// Components.
+import Icon from 'flavours/glitch/components/icon';
+
+// Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+// Handlers.
+const handlers = {
+
+ // This function activates the dropdown item.
+ activate (e) {
+ const {
+ name,
+ onChange,
+ onClose,
+ options: { on },
+ } = this.props;
+
+ // If the escape key was pressed, we close the dropdown.
+ if (e.key === 'Escape' && onClose) {
+ onClose();
+
+ // Otherwise, we both close the dropdown and change the value.
+ } else if (onChange && (!e.key || e.key === 'Enter')) {
+ e.preventDefault(); // Prevents change in focus on click
+ if ((on === null || typeof on === 'undefined') && onClose) {
+ onClose();
+ }
+ onChange(name);
+ }
+ },
+
+};
+
+// The component.
+export default class ComposerOptionsDropdownItem extends React.PureComponent {
+
+ // Constructor.
+ constructor (props) {
+ super(props);
+ assignHandlers(this, handlers);
+ }
+
+ // Rendering.
+ render () {
+ const { activate } = this.handlers;
+ const {
+ active,
+ options: {
+ icon,
+ meta,
+ on,
+ text,
+ },
+ } = this.props;
+ const computedClass = classNames('composer--options--dropdown_item', {
+ active,
+ lengthy: meta,
+ 'toggled-off': !on && on !== null && typeof on !== 'undefined',
+ 'toggled-on': on,
+ 'with-icon': icon,
+ });
+
+ // The result.
+ return (
+
+ {function () {
+
+ // We render a `
` if we were provided an `on`
+ // property, and otherwise show an `` if available.
+ switch (true) {
+ case on !== null && typeof on !== 'undefined':
+ return (
+
+ );
+ case !!icon:
+ return (
+
+ );
+ default:
+ return null;
+ }
+ }()}
+ {meta ? (
+
+ {text}
+ {meta}
+
+ ) : {text}
}
+
+ );
+ }
+
+};
+
+// Props.
+ComposerOptionsDropdownItem.propTypes = {
+ active: PropTypes.bool,
+ name: PropTypes.string,
+ onChange: PropTypes.func,
+ onClose: PropTypes.func,
+ options: PropTypes.shape({
+ icon: PropTypes.string,
+ meta: PropTypes.node,
+ on: PropTypes.bool,
+ text: PropTypes.node,
+ }),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js
new file mode 100644
index 00000000000..ee633e8654f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/index.js
@@ -0,0 +1,321 @@
+// Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+ FormattedMessage,
+ defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+// Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import TextIconButton from 'flavours/glitch/components/text_icon_button';
+import Dropdown from './dropdown';
+
+// Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import {
+ assignHandlers,
+ hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+// Messages.
+const messages = defineMessages({
+ advanced_options_icon_title: {
+ defaultMessage: 'Advanced options',
+ id: 'advanced_options.icon_title',
+ },
+ attach: {
+ defaultMessage: 'Attach...',
+ id: 'compose.attach',
+ },
+ change_privacy: {
+ defaultMessage: 'Adjust status privacy',
+ id: 'privacy.change',
+ },
+ direct_long: {
+ defaultMessage: 'Post to mentioned users only',
+ id: 'privacy.direct.long',
+ },
+ direct_short: {
+ defaultMessage: 'Direct',
+ id: 'privacy.direct.short',
+ },
+ doodle: {
+ defaultMessage: 'Draw something',
+ id: 'compose.attach.doodle',
+ },
+ local_only_long: {
+ defaultMessage: 'Do not post to other instances',
+ id: 'advanced-options.local-only.long',
+ },
+ local_only_short: {
+ defaultMessage: 'Local-only',
+ id: 'advanced-options.local-only.short',
+ },
+ private_long: {
+ defaultMessage: 'Post to followers only',
+ id: 'privacy.private.long',
+ },
+ private_short: {
+ defaultMessage: 'Followers-only',
+ id: 'privacy.private.short',
+ },
+ public_long: {
+ defaultMessage: 'Post to public timelines',
+ id: 'privacy.public.long',
+ },
+ public_short: {
+ defaultMessage: 'Public',
+ id: 'privacy.public.short',
+ },
+ sensitive: {
+ defaultMessage: 'Mark media as sensitive',
+ id: 'compose_form.sensitive',
+ },
+ spoiler: {
+ defaultMessage: 'Hide text behind warning',
+ id: 'compose_form.spoiler',
+ },
+ unlisted_long: {
+ defaultMessage: 'Do not show in public timelines',
+ id: 'privacy.unlisted.long',
+ },
+ unlisted_short: {
+ defaultMessage: 'Unlisted',
+ id: 'privacy.unlisted.short',
+ },
+ upload: {
+ defaultMessage: 'Upload a file',
+ id: 'compose.attach.upload',
+ },
+});
+
+// Handlers.
+const handlers = {
+
+ // Handles file selection.
+ changeFiles ({ target: { files } }) {
+ const { onUpload } = this.props;
+ if (files.length && onUpload) {
+ onUpload(files);
+ }
+ },
+
+ // Handles attachment clicks.
+ clickAttach (name) {
+ const { fileElement } = this;
+ const { onDoodleOpen } = this.props;
+
+ // We switch over the name of the option.
+ switch (name) {
+ case 'upload':
+ if (fileElement) {
+ fileElement.click();
+ }
+ return;
+ case 'doodle':
+ if (onDoodleOpen) {
+ onDoodleOpen();
+ }
+ return;
+ }
+ },
+
+ // Handles a ref to the file input.
+ refFileElement (fileElement) {
+ this.fileElement = fileElement;
+ },
+};
+
+// The component.
+export default class ComposerOptions extends React.PureComponent {
+
+ // Constructor.
+ constructor (props) {
+ super(props);
+ assignHandlers(this, handlers);
+
+ // Instance variables.
+ this.fileElement = null;
+ }
+
+ // Rendering.
+ render () {
+ const {
+ changeFiles,
+ clickAttach,
+ refFileElement,
+ } = this.handlers;
+ const {
+ acceptContentTypes,
+ disabled,
+ doNotFederate,
+ full,
+ hasMedia,
+ intl,
+ onChangeSensitivity,
+ onChangeVisibility,
+ onModalClose,
+ onModalOpen,
+ onToggleAdvancedOption,
+ privacy,
+ resetFileKey,
+ sensitive,
+ spoiler,
+ } = this.props;
+
+ // We predefine our privacy items so that we can easily pick the
+ // dropdown icon later.
+ const privacyItems = {
+ direct: {
+ icon: 'envelope',
+ meta: ,
+ name: 'direct',
+ text: ,
+ },
+ private: {
+ icon: 'lock',
+ meta: ,
+ name: 'private',
+ text: ,
+ },
+ public: {
+ icon: 'globe',
+ meta: ,
+ name: 'public',
+ text: ,
+ },
+ unlisted: {
+ icon: 'unlock-alt',
+ meta: ,
+ name: 'unlisted',
+ text: ,
+ },
+ };
+
+ // The result.
+ return (
+
+
+
,
+ },
+ {
+ icon: 'paint-brush',
+ name: 'doodle',
+ text:
,
+ },
+ ]}
+ onChange={clickAttach}
+ onModalClose={onModalClose}
+ onModalOpen={onModalOpen}
+ title={messages.attach}
+ />
+
+ {({ scale }) => (
+
+
+
+ )}
+
+
+
+
+
,
+ name: 'do_not_federate',
+ on: doNotFederate,
+ text:
,
+ },
+ ]}
+ onChange={onToggleAdvancedOption}
+ onModalClose={onModalClose}
+ onModalOpen={onModalOpen}
+ title={intl.formatMessage(messages.advanced_options_icon_title)}
+ />
+
+ );
+ }
+
+}
+
+// Props.
+ComposerOptions.propTypes = {
+ acceptContentTypes: PropTypes.string,
+ disabled: PropTypes.bool,
+ doNotFederate: PropTypes.bool,
+ full: PropTypes.bool,
+ hasMedia: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ onChangeSensitivity: PropTypes.func,
+ onChangeVisibility: PropTypes.func,
+ onDoodleOpen: PropTypes.func,
+ onModalClose: PropTypes.func,
+ onModalOpen: PropTypes.func,
+ onToggleAdvancedOption: PropTypes.func,
+ onUpload: PropTypes.func,
+ privacy: PropTypes.string,
+ resetFileKey: PropTypes.string,
+ sensitive: PropTypes.bool,
+ spoiler: PropTypes.bool,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/publisher/index.js b/app/javascript/flavours/glitch/features/composer/publisher/index.js
new file mode 100644
index 00000000000..85de80a9fc8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/publisher/index.js
@@ -0,0 +1,119 @@
+// Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+ defineMessages,
+ FormattedMessage,
+} from 'react-intl';
+import { length } from 'stringz';
+
+// Components.
+import Button from 'flavours/glitch/components/button';
+import Icon from 'flavours/glitch/components/icon';
+
+// Utils.
+import { maxChars } from 'flavours/glitch/util/initial_state';
+
+// Messages.
+const messages = defineMessages({
+ publish: {
+ defaultMessage: 'Toot',
+ id: 'compose_form.publish',
+ },
+ publishLoud: {
+ defaultMessage: '{publish}!',
+ id: 'compose_form.publish_loud',
+ },
+});
+
+// The component.
+export default function ComposerPublisher ({
+ countText,
+ disabled,
+ intl,
+ onSecondarySubmit,
+ onSubmit,
+ privacy,
+ sideArm,
+}) {
+ const diff = maxChars - length(countText || '');
+ const computedClass = classNames('composer--publisher', {
+ disabled: disabled || diff < 0,
+ over: diff < 0,
+ });
+
+ // The result.
+ return (
+
+ {diff}
+ {sideArm && sideArm !== 'none' ? (
+
+
+
+ }
+ title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
+ onClick={onSecondarySubmit}
+ disabled={disabled || diff < 0}
+ />
+ ) : null}
+
+
+
+
+ );
+ case privacy === 'public':
+ return (
+
+ }}
+ />
+
+ );
+ default:
+ return ;
+ }
+ }()}
+ title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
+ onClick={onSubmit}
+ disabled={disabled || diff < 0}
+ />
+
+ );
+}
+
+// Props.
+ComposerPublisher.propTypes = {
+ countText: PropTypes.string,
+ disabled: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ onSecondarySubmit: PropTypes.func,
+ onSubmit: PropTypes.func,
+ privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
+ sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/reply/index.js b/app/javascript/flavours/glitch/features/composer/reply/index.js
new file mode 100644
index 00000000000..2823415d28b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/reply/index.js
@@ -0,0 +1,106 @@
+// Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages } from 'react-intl';
+
+// Components.
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import IconButton from 'flavours/glitch/components/icon_button';
+
+// Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { isRtl } from 'flavours/glitch/util/rtl';
+
+// Messages.
+const messages = defineMessages({
+ cancel: {
+ defaultMessage: 'Cancel',
+ id: 'reply_indicator.cancel',
+ },
+});
+
+// Handlers.
+const handlers = {
+
+ // Handles a click on the "close" button.
+ click () {
+ const { onCancel } = this.props;
+ if (onCancel) {
+ onCancel();
+ }
+ },
+
+ // Handles a click on the status's account.
+ clickAccount () {
+ const {
+ account,
+ history,
+ } = this.props;
+ if (history) {
+ history.push(`/accounts/${account.get('id')}`);
+ }
+ },
+};
+
+// The component.
+export default class ComposerReply extends React.PureComponent {
+
+ // Constructor.
+ constructor (props) {
+ super(props);
+ assignHandlers(this, handlers);
+ }
+
+ // Rendering.
+ render () {
+ const {
+ click,
+ clickAccount,
+ } = this.handlers;
+ const {
+ account,
+ content,
+ intl,
+ } = this.props;
+
+ // The result.
+ return (
+
+
+
+ {account ? (
+
+
+
+
+ ) : null}
+
+
+
+ );
+ }
+
+}
+
+ComposerReply.propTypes = {
+ account: ImmutablePropTypes.map,
+ content: PropTypes.string,
+ history: PropTypes.object,
+ intl: PropTypes.object.isRequired,
+ onCancel: PropTypes.func,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/spoiler/index.js b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
new file mode 100644
index 00000000000..730ab2205a5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
@@ -0,0 +1,92 @@
+// Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+// Components.
+import Collapsable from 'flavours/glitch/components/collapsable';
+
+// Utils.
+import {
+ assignHandlers,
+ hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+// Messages.
+const messages = defineMessages({
+ placeholder: {
+ defaultMessage: 'Write your warning here',
+ id: 'compose_form.spoiler_placeholder',
+ },
+});
+
+// Handlers.
+const handlers = {
+
+ // Handles a keypress.
+ keyDown ({
+ ctrlKey,
+ keyCode,
+ metaKey,
+ }) {
+ const { onSubmit } = this.props;
+
+ // We submit the status on control/meta + enter.
+ if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) {
+ onSubmit();
+ }
+ },
+};
+
+// The component.
+export default class ComposerSpoiler extends React.PureComponent {
+
+ // Constructor.
+ constructor (props) {
+ super(props);
+ assignHandlers(this, handlers);
+ }
+
+ // Rendering.
+ render () {
+ const { keyDown } = this.handlers;
+ const {
+ hidden,
+ intl,
+ onChange,
+ text,
+ } = this.props;
+
+ // The result.
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+// Props.
+ComposerSpoiler.propTypes = {
+ hidden: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ onChange: PropTypes.func,
+ onSubmit: PropTypes.func,
+ text: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js
new file mode 100644
index 00000000000..ad0a35d7f2c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js
@@ -0,0 +1,297 @@
+// Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import {
+ defineMessages,
+ FormattedMessage,
+} from 'react-intl';
+import Textarea from 'react-textarea-autosize';
+
+// Components.
+import EmojiPicker from 'flavours/glitch/features/emoji_picker';
+import ComposerTextareaSuggestions from './suggestions';
+
+// Utils.
+import { isRtl } from 'flavours/glitch/util/rtl';
+import {
+ assignHandlers,
+ hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+// Messages.
+const messages = defineMessages({
+ placeholder: {
+ defaultMessage: 'What is on your mind?',
+ id: 'compose_form.placeholder',
+ },
+});
+
+// Handlers.
+const handlers = {
+
+ // When blurring the textarea, suggestions are hidden.
+ blur () {
+ this.setState({ suggestionsHidden: true });
+ },
+
+ // When the contents of the textarea change, we have to pull up new
+ // autosuggest suggestions if applicable, and also change the value
+ // of the textarea in our store.
+ change ({
+ target: {
+ selectionStart,
+ value,
+ },
+ }) {
+ const {
+ onChange,
+ onSuggestionsFetchRequested,
+ onSuggestionsClearRequested,
+ } = this.props;
+ const { lastToken } = this.state;
+
+ // This gets the token at the caret location, if it begins with an
+ // `@` (mentions) or `:` (shortcodes).
+ const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/);
+ const right = value.slice(selectionStart).search(/[\s\u200B]/);
+ const token = function () {
+ switch (true) {
+ case left < 0 || /[@:]/.test(!value[left]):
+ return null;
+ case right < 0:
+ return value.slice(left);
+ default:
+ return value.slice(left, right + selectionStart).trim().toLowerCase();
+ }
+ }();
+
+ // We only request suggestions for tokens which are at least 3
+ // characters long.
+ if (onSuggestionsFetchRequested && token && token.length >= 3) {
+ if (lastToken !== token) {
+ this.setState({
+ lastToken: token,
+ selectedSuggestion: 0,
+ tokenStart: left,
+ });
+ onSuggestionsFetchRequested(token);
+ }
+ } else {
+ this.setState({ lastToken: null });
+ if (onSuggestionsClearRequested) {
+ onSuggestionsClearRequested();
+ }
+ }
+
+ // Updates the value of the textarea.
+ if (onChange) {
+ onChange(value);
+ }
+ },
+
+ // Handles a click on an autosuggestion.
+ clickSuggestion (index) {
+ const { textarea } = this;
+ const {
+ onSuggestionSelected,
+ suggestions,
+ } = this.props;
+ const {
+ lastToken,
+ tokenStart,
+ } = this.state;
+ onSuggestionSelected(tokenStart, lastToken, suggestions.get(index));
+ textarea.focus();
+ },
+
+ // Handles a keypress. If the autosuggestions are visible, we need
+ // to allow keypresses to navigate and sleect them.
+ keyDown (e) {
+ const {
+ disabled,
+ onSubmit,
+ onSuggestionSelected,
+ suggestions,
+ } = this.props;
+ const {
+ lastToken,
+ suggestionsHidden,
+ selectedSuggestion,
+ tokenStart,
+ } = this.state;
+
+ // Keypresses do nothing if the composer is disabled.
+ if (disabled) {
+ e.preventDefault();
+ return;
+ }
+
+ // Switches over the pressed key.
+ switch(e.key) {
+
+ // On arrow down, we pick the next suggestion.
+ case 'ArrowDown':
+ if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+ }
+ return;
+
+ // On arrow up, we pick the previous suggestion.
+ case 'ArrowUp':
+ if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+ }
+ return;
+
+ // On enter or tab, we select the suggestion.
+ case 'Enter':
+ case 'Tab':
+ if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ e.stopPropagation();
+ onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion));
+ }
+ return;
+ }
+
+ // We submit the status on control/meta + enter.
+ if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+ onSubmit();
+ }
+ },
+
+ // When the escape key is released, we either close the suggestions
+ // window or focus the UI.
+ keyUp ({ key }) {
+ const { suggestionsHidden } = this.state;
+ if (key === 'Escape') {
+ if (!suggestionsHidden) {
+ this.setState({ suggestionsHidden: true });
+ } else {
+ document.querySelector('.ui').parentElement.focus();
+ }
+ }
+ },
+
+ // Handles the pasting of images into the composer.
+ paste (e) {
+ const { onPaste } = this.props;
+ let d;
+ if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) {
+ onPaste(d);
+ e.preventDefault();
+ }
+ },
+
+ // Saves a reference to the textarea.
+ refTextarea (textarea) {
+ this.textarea = textarea;
+ },
+};
+
+// The component.
+export default class ComposerTextarea extends React.Component {
+
+ // Constructor.
+ constructor (props) {
+ super(props);
+ assignHandlers(this, handlers);
+ this.state = {
+ suggestionsHidden: false,
+ selectedSuggestion: 0,
+ lastToken: null,
+ tokenStart: 0,
+ };
+
+ // Instance variables.
+ this.textarea = null;
+ }
+
+ // When we receive new suggestions, we unhide the suggestions window
+ // if we didn't have any suggestions before.
+ componentWillReceiveProps (nextProps) {
+ const { suggestions } = this.props;
+ const { suggestionsHidden } = this.state;
+ if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) {
+ this.setState({ suggestionsHidden: false });
+ }
+ }
+
+ // Rendering.
+ render () {
+ const {
+ blur,
+ change,
+ clickSuggestion,
+ keyDown,
+ keyUp,
+ paste,
+ refTextarea,
+ } = this.handlers;
+ const {
+ autoFocus,
+ disabled,
+ intl,
+ onPickEmoji,
+ suggestions,
+ value,
+ } = this.props;
+ const {
+ selectedSuggestion,
+ suggestionsHidden,
+ } = this.state;
+
+ // The result.
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+// Props.
+ComposerTextarea.propTypes = {
+ autoFocus: PropTypes.bool,
+ disabled: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ onChange: PropTypes.func,
+ onPaste: PropTypes.func,
+ onPickEmoji: PropTypes.func,
+ onSubmit: PropTypes.func,
+ onSuggestionsClearRequested: PropTypes.func,
+ onSuggestionsFetchRequested: PropTypes.func,
+ onSuggestionSelected: PropTypes.func,
+ suggestions: ImmutablePropTypes.list,
+ value: PropTypes.string,
+};
+
+// Default props.
+ComposerTextarea.defaultProps = { autoFocus: true };
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
new file mode 100644
index 00000000000..b9069691080
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
@@ -0,0 +1,41 @@
+// Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+// Components.
+import ComposerTextareaSuggestionsItem from './item';
+
+// The component.
+export default function ComposerTextareaSuggestions ({
+ hidden,
+ onSuggestionClick,
+ suggestions,
+ value,
+}) {
+ const computedClass = classNames('comoser--textarea--suggestions', { hidden: hidden || suggestions.isEmpty() });
+
+ return (
+
+ {!hidden ? suggestions.map(
+ (suggestion, index) => (
+
+ )
+ ) : null}
+
+ );
+}
+
+ComposerTextareaSuggestions.propTypes = {
+ hidden: PropTypes.bool,
+ onSuggestionClick: PropTypes.func,
+ suggestions: ImmutablePropTypes.list,
+ value: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
new file mode 100644
index 00000000000..08c99ed0ef6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
@@ -0,0 +1,101 @@
+// Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+// Components.
+import AccountContainer from 'flavours/glitch/containers/account_container';
+
+// Utils.
+import { unicodeMapping } from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+// Gets our asset host from the environment, if available.
+const assetHost = ((process || {}).env || {}).CDN_HOST || '';
+
+// Handlers.
+const handlers = {
+
+ // Handles a click on a suggestion.
+ click (e) {
+ const {
+ index,
+ onClick,
+ } = this.props;
+ if (onClick) {
+ e.preventDefault();
+ onClick(index);
+ }
+ },
+};
+
+// The component.
+export default class ComposerTextareaSuggestionsItem extends React.Component {
+
+ // Constructor.
+ constructor (props) {
+ super(props);
+ assignHandlers(this, handlers);
+ }
+
+ // Rendering.
+ render () {
+ const { click } = this.handlers;
+ const {
+ selected,
+ suggestion,
+ } = this.props;
+ const computedClass = classNames('composer--textarea--suggestions--item', { selected });
+
+ // The result.
+ return (
+
+ { // If the suggestion is an object, then we render an emoji.
+ // Otherwise, we render an account.
+ typeof suggestion === 'object' ? function () {
+ const url = function () {
+ if (suggestion.custom) {
+ return suggestion.imageUrl;
+ } else {
+ const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')];
+ if (!mapping) {
+ return null;
+ }
+ return `${assetHost}/emoji/${mapping.filename}.svg`;
+ }
+ }();
+ return url ? (
+
+
+ {suggestion.colons}
+
+ ) : null;
+ }() : (
+
+ )
+ }
+
+ );
+ }
+
+}
+
+// Props.
+ComposerTextareaSuggestionsItem.propTypes = {
+ index: PropTypes.number,
+ onClick: PropTypes.func,
+ selected: PropTypes.bool,
+ suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
new file mode 100644
index 00000000000..ab46a3046d6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
@@ -0,0 +1,54 @@
+// Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+// Components.
+import ComposerUploadFormItem from './item';
+import ComposerUploadFormProgress from './progress';
+
+// The component.
+export default function ComposerUploadForm ({
+ active,
+ intl,
+ media,
+ onChangeDescription,
+ onRemove,
+ progress,
+}) {
+ const computedClass = classNames('composer--upload_form', { uploading: active });
+
+ // We need `media` in order to be able to render.
+ if (!media) {
+ return null;
+ }
+
+ // The result.
+ return (
+
+ {active ? : null}
+ {media.map(item => (
+
+ ))}
+
+ );
+}
+
+// Props.
+ComposerUploadForm.propTypes = {
+ active: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ media: ImmutablePropTypes.list,
+ onChangeDescription: PropTypes.func,
+ onRemove: PropTypes.func,
+ progress: PropTypes.number,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
new file mode 100644
index 00000000000..bd67e722741
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
@@ -0,0 +1,176 @@
+// Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+ FormattedMessage,
+ defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+// Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+
+// Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+// Messages.
+const messages = defineMessages({
+ undo: {
+ defaultMessage: 'Undo',
+ id: 'upload_form.undo',
+ },
+ description: {
+ defaultMessage: 'Describe for the visually impaired',
+ id: 'upload_form.description',
+ },
+});
+
+// Handlers.
+const handlers = {
+
+ // On blur, we save the description for the media item.
+ blur () {
+ const {
+ id,
+ onChangeDescription,
+ } = this.props;
+ const { dirtyDescription } = this.state;
+ if (id && onChangeDescription && dirtyDescription !== null) {
+ this.setState({
+ dirtyDescription: null,
+ focused: false,
+ });
+ onChangeDescription(id, dirtyDescription);
+ }
+ },
+
+ // When the value of our description changes, we store it in the
+ // temp value `dirtyDescription` in our state.
+ change ({ target: { value } }) {
+ this.setState({ dirtyDescription: value });
+ },
+
+ // Records focus on the media item.
+ focus () {
+ this.setState({ focused: true });
+ },
+
+ // Records the start of a hover over the media item.
+ mouseEnter () {
+ this.setState({ hovered: true });
+ },
+
+ // Records the end of a hover over the media item.
+ mouseLeave () {
+ this.setState({ hovered: false });
+ },
+
+ // Removes the media item.
+ remove () {
+ const {
+ id,
+ onRemove,
+ } = this.props;
+ if (id && onRemove) {
+ onRemove(id);
+ }
+ },
+};
+
+// The component.
+export default class ComposerUploadFormItem extends React.PureComponent {
+
+ // Constructor.
+ constructor (props) {
+ super(props);
+ assignHandlers(handlers);
+ this.state = {
+ hovered: false,
+ focused: false,
+ dirtyDescription: null,
+ };
+ }
+
+ // Rendering.
+ render () {
+ const {
+ blur,
+ change,
+ focus,
+ mouseEnter,
+ mouseLeave,
+ remove,
+ } = this.handlers;
+ const {
+ description,
+ intl,
+ preview,
+ } = this.props;
+ const {
+ focused,
+ hovered,
+ dirtyDescription,
+ } = this.state;
+ const computedClass = classNames('composer--upload_form--item', { active: hovered || focused });
+
+ // The result.
+ return (
+
+
+ {({ scale }) => (
+
+
+
+
+
+
+
+ )}
+
+
+ );
+ }
+
+}
+
+// Props.
+ComposerUploadFormItem.propTypes = {
+ description: PropTypes.string,
+ id: PropTypes.number,
+ intl: PropTypes.object.isRequired,
+ onChangeDescription: PropTypes.func,
+ onRemove: PropTypes.func,
+ preview: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
new file mode 100644
index 00000000000..9dac6acf9e1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
@@ -0,0 +1,52 @@
+// Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+ defineMessages,
+ FormattedMessage,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+// Components.
+import Icon from 'flavours/glitch/components/icon';
+
+// Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+
+// Messages.
+const messages = defineMessages({
+ upload: {
+ defaultMessage: 'Uploading...',
+ id: 'upload_progress.label',
+ },
+});
+
+// The component.
+export default function ComposerUploadFormProgress ({ progress }) {
+
+ // The result.
+ return (
+
+
+
+
+
+
+ {({ width }) =>
+
+ }
+
+
+
+
+ );
+}
+
+// Props.
+ComposerUploadFormProgress.propTypes = { progress: PropTypes.number };
diff --git a/app/javascript/flavours/glitch/features/composer/warning/index.js b/app/javascript/flavours/glitch/features/composer/warning/index.js
new file mode 100644
index 00000000000..c225b50e869
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/warning/index.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+// This is the spring used with our motion.
+const motionSpring = spring(1, { damping: 35, stiffness: 400 });
+
+// Messages.
+const messages = defineMessages({
+ disclaimer: {
+ defaultMessage: 'Your account is not {locked}. Anyone can follow you to view your follower-only posts.',
+ id: 'compose_form.lock_disclaimer',
+ },
+ locked: {
+ defaultMessage: 'locked',
+ id: 'compose_form.lock_disclaimer.lock',
+ },
+});
+
+// The component.
+export default function ComposerWarning () {
+ return (
+
+ {({ opacity, scaleX, scaleY }) => (
+
+ }}
+ />
+
+ )}
+
+ );
+}
+
+ComposerWarning.propTypes = {};
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js
similarity index 100%
rename from app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
rename to app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/drawer/components/search.js
similarity index 100%
rename from app/javascript/flavours/glitch/features/compose/components/search.js
rename to app/javascript/flavours/glitch/features/drawer/components/search.js
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/drawer/components/search_results.js
similarity index 100%
rename from app/javascript/flavours/glitch/features/compose/components/search_results.js
rename to app/javascript/flavours/glitch/features/drawer/components/search_results.js
diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js
similarity index 100%
rename from app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
rename to app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/drawer/containers/search_container.js
similarity index 100%
rename from app/javascript/flavours/glitch/features/compose/containers/search_container.js
rename to app/javascript/flavours/glitch/features/drawer/containers/search_container.js
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js
similarity index 100%
rename from app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
rename to app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js
diff --git a/app/javascript/flavours/glitch/features/drawer/index.js b/app/javascript/flavours/glitch/features/drawer/index.js
new file mode 100644
index 00000000000..8386ae47cfc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/index.js
@@ -0,0 +1,198 @@
+// Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+import spring from 'react-motion/lib/spring';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+
+// Actions.
+import { changeComposing } from 'flavours/glitch/actions/compose';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+// Components.
+import Icon from 'flavours/glitch/components/icon';
+import Compose from 'flavours/glitch/features/compose';
+import NavigationContainer from './containers/navigation_container';
+import SearchContainer from './containers/search_container';
+import SearchResultsContainer from './containers/search_results_container';
+
+// Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import {
+ assignHandlers,
+ conditionalRender,
+} from 'flavours/glitch/util/react_helpers';
+
+// Messages.
+const messages = defineMessages({
+ community: {
+ defaultMessage: 'Local timeline',
+ id: 'navigation_bar.community_timeline',
+ },
+ home_timeline: {
+ defaultMessage: 'Home',
+ id: 'tabs_bar.home',
+ },
+ logout: {
+ defaultMessage: 'Logout',
+ id: 'navigation_bar.logout',
+ },
+ notifications: {
+ defaultMessage: 'Notifications',
+ id: 'tabs_bar.notifications',
+ },
+ public: {
+ defaultMessage: 'Federated timeline',
+ id: 'navigation_bar.public_timeline',
+ },
+ settings: {
+ defaultMessage: 'App settings',
+ id: 'navigation_bar.app_settings',
+ },
+ start: {
+ defaultMessage: 'Getting started',
+ id: 'getting_started.heading',
+ },
+});
+
+// State mapping.
+const mapStateToProps = state => ({
+ columns: state.getIn(['settings', 'columns']),
+ showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+});
+
+// Dispatch mapping.
+const mapDispatchToProps = dispatch => ({
+ onBlur () {
+ dispatch(changeComposing(false));
+ },
+ onFocus () {
+ dispatch(changeComposing(true));
+ },
+ onSettingsOpen () {
+ dispatch(openModal('SETTINGS', {}));
+ },
+});
+
+// The component.
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default function Drawer ({
+ columns,
+ intl,
+ multiColumn,
+ onBlur,
+ onFocus,
+ onSettingsOpen,
+ showSearch,
+}) {
+
+ // Only renders the component if the column isn't being shown.
+ const renderForColumn = conditionalRender.bind(
+ columnId => !columns.some(column => column.get('id') === columnId)
+ );
+
+ // The result.
+ return (
+
+ {multiColumn ? (
+
+
+ {renderForColumn('HOME', (
+
+ ))}
+ {renderForColumn('NOTIFICATIONS', (
+
+ ))}
+ {renderForColumn('COMMUNITY', (
+
+ ))}
+ {renderForColumn('PUBLIC', (
+
+ ))}
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ {({ x }) => (
+
+ )}
+
+
+
+ );
+}
+
+// Props.
+Drawer.propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ columns: ImmutablePropTypes.list.isRequired,
+ multiColumn: PropTypes.bool,
+ showSearch: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+};
diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
similarity index 85%
rename from app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js
rename to app/javascript/flavours/glitch/features/emoji_picker/index.js
index cf89f91d336..4b1ef6c9724 100644
--- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -1,3 +1,8 @@
+import { connect } from 'react-redux';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from 'flavours/glitch/actions/emojis';
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
@@ -25,6 +30,80 @@ const messages = defineMessages({
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
+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) {
+ emojis = emojis.concat(DEFAULTS.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: state.getIn(['settings', 'skinTone']),
+ frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
+});
+
+const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+ onSkinTone: skinTone => {
+ dispatch(changeSetting(['skinTone'], skinTone));
+ },
+
+ onPickEmoji: emoji => {
+ dispatch(useEmoji(emoji));
+
+ if (onPickEmoji) {
+ onPickEmoji(emoji);
+ }
+ },
+});
+
const assetHost = process.env.CDN_HOST || '';
let EmojiPicker, Emoji; // load asynchronously
@@ -277,6 +356,7 @@ class EmojiPickerMenu extends React.PureComponent {
}
+@connect(mapStateToProps, mapDispatchToProps)
@injectIntl
export default class EmojiPickerDropdown extends React.PureComponent {
diff --git a/app/javascript/flavours/glitch/features/standalone/compose/index.js b/app/javascript/flavours/glitch/features/standalone/compose/index.js
index b33c21cb5b6..a77b594485e 100644
--- a/app/javascript/flavours/glitch/features/standalone/compose/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/compose/index.js
@@ -1,5 +1,5 @@
import React from 'react';
-import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
+import Composer from 'flavours/glitch/features/composer';
import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container';
import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container';
import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container';
@@ -9,7 +9,7 @@ export default class Compose extends React.PureComponent {
render () {
return (
-
+
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index aaa36b69611..52a8ab5ec38 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -134,7 +134,7 @@ function removeMedia(state, mediaId) {
const insertSuggestion = (state, position, token, completion) => {
return state.withMutations(map => {
- map.update('text', oldText => `${oldText.slice(0, position)}${completion}\u200B${oldText.slice(position + token.length)}`);
+ map.update('text', oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`);
map.set('suggestion_token', null);
map.update('suggestions', ImmutableList(), list => list.clear());
map.set('focusDate', new Date());
diff --git a/app/javascript/flavours/glitch/styles/components/compose.scss b/app/javascript/flavours/glitch/styles/components/compose.scss
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/app/javascript/flavours/glitch/util/dom_helpers.js b/app/javascript/flavours/glitch/util/dom_helpers.js
new file mode 100644
index 00000000000..ee95ef8dd53
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/dom_helpers.js
@@ -0,0 +1,6 @@
+// Package imports.
+import detectPassiveEvents from 'detect-passive-events';
+
+// This will either be a passive lister options object (if passive
+// events are supported), or `false`.
+export const withPassive = detectPassiveEvents.hasSupport ? { passive: true } : false;
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
index 607d6b9b048..530bca7ef6c 100644
--- a/app/javascript/flavours/glitch/util/initial_state.js
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -18,5 +18,6 @@ export const boostModal = getMeta('boost_modal');
export const favouriteModal = getMeta('favourite_modal');
export const deleteModal = getMeta('delete_modal');
export const me = getMeta('me');
+export const maxChars = getMeta('max_toot_chars') || 500;
export default initialState;
diff --git a/app/javascript/flavours/glitch/util/react_helpers.js b/app/javascript/flavours/glitch/util/react_helpers.js
new file mode 100644
index 00000000000..0826f3584f0
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/react_helpers.js
@@ -0,0 +1,21 @@
+// This function binds the given `handlers` to the `target`.
+export function assignHandlers (target, handlers) {
+ if (!target || !handlers) {
+ return;
+ }
+
+ // We just bind each handler to the `target`.
+ const handle = target.handlers = {};
+ handlers.keys().forEach(
+ key => handle.key = key.bind(target)
+ );
+}
+
+// This function only returns the component if the result of calling
+// `test` with `data` is `true`. Useful with funciton binding.
+export function conditionalRender (test, data, component) {
+ return test ? component : null;
+}
+
+// This object provides props to make the component not visible.
+export const hiddenComponent = { style: { display: 'none' } };
diff --git a/app/javascript/flavours/glitch/util/redux_helpers.js b/app/javascript/flavours/glitch/util/redux_helpers.js
new file mode 100644
index 00000000000..3bc8bc86fbc
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/redux_helpers.js
@@ -0,0 +1,7 @@
+// Merges react-redux props.
+export function mergeProps (stateProps, dispatchProps, ownProps) {
+ Object.assign({}, ownProps, {
+ dispatch: Object.assign({}, dispatchProps, ownProps.dispatch || {}),
+ state: Object.assign({}, stateProps, ownProps.state || {}),
+ });
+}