diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 0ee663766..94062f2be 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -383,7 +383,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
};
};
-export function selectComposeSuggestion(position, token, suggestion) {
+export function selectComposeSuggestion(position, token, suggestion, path) {
return (dispatch, getState) => {
let completion, startPosition;
@@ -405,6 +405,7 @@ export function selectComposeSuggestion(position, token, suggestion) {
position: startPosition,
token,
completion,
+ path,
});
};
};
diff --git a/app/javascript/mastodon/components/autosuggest_input.js b/app/javascript/mastodon/components/autosuggest_input.js
new file mode 100644
index 000000000..bb8ab60db
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_input.js
@@ -0,0 +1,229 @@
+import React from 'react';
+import AutosuggestAccountContainer from '../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 '../rtl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+import { List as ImmutableList } from 'immutable';
+
+const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
+ let word;
+
+ let left = str.slice(0, caretPosition).search(/\S+$/);
+ let right = str.slice(caretPosition).search(/\s/);
+
+ if (right < 0) {
+ word = str.slice(left);
+ } else {
+ word = str.slice(left, right + caretPosition);
+ }
+
+ if (!word || word.trim().length < 3 || searchTokens.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 AutosuggestInput 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,
+ autoFocus: PropTypes.bool,
+ className: PropTypes.string,
+ id: PropTypes.string,
+ searchTokens: PropTypes.list,
+ maxLength: PropTypes.number,
+ };
+
+ static defaultProps = {
+ autoFocus: true,
+ searchTokens: ImmutableList(['@', ':', '#']),
+ };
+
+ state = {
+ suggestionsHidden: true,
+ focused: false,
+ selectedSuggestion: 0,
+ lastToken: null,
+ tokenStart: 0,
+ };
+
+ onChange = (e) => {
+ const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
+
+ 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;
+ }
+
+ if (e.which === 229 || e.isComposing) {
+ // Ignore key events during text composition
+ // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
+ return;
+ }
+
+ switch(e.key) {
+ case 'Escape':
+ if (suggestions.size === 0 || suggestionsHidden) {
+ document.querySelector('.ui').parentElement.focus();
+ } else {
+ 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);
+ }
+
+ onBlur = () => {
+ this.setState({ suggestionsHidden: true, focused: false });
+ }
+
+ onFocus = () => {
+ this.setState({ focused: 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.input.focus();
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
+ this.setState({ suggestionsHidden: false });
+ }
+ }
+
+ setInput = (c) => {
+ this.input = c;
+ }
+
+ renderSuggestion = (suggestion, i) => {
+ const { selectedSuggestion } = this.state;
+ let inner, key;
+
+ if (typeof suggestion === 'object') {
+ inner = ;
+ key = suggestion.id;
+ } else if (suggestion[0] === '#') {
+ inner = suggestion;
+ key = suggestion;
+ } else {
+ inner = ;
+ key = suggestion;
+ }
+
+ return (
+
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js
index 383e37eb6..211601d52 100644
--- a/app/javascript/mastodon/features/compose/components/poll_form.js
+++ b/app/javascript/mastodon/features/compose/components/poll_form.js
@@ -5,6 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from 'mastodon/components/icon_button';
import Icon from 'mastodon/components/icon';
+import AutosuggestInput from 'mastodon/components/autosuggest_input';
import classNames from 'classnames';
const messages = defineMessages({
@@ -27,6 +28,10 @@ class Option extends React.PureComponent {
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onToggleMultiple: PropTypes.func.isRequired,
+ suggestions: ImmutablePropTypes.list,
+ onClearSuggestions: PropTypes.func.isRequired,
+ onFetchSuggestions: PropTypes.func.isRequired,
+ onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@@ -38,12 +43,25 @@ class Option extends React.PureComponent {
this.props.onRemove(this.props.index);
};
+
handleToggleMultiple = e => {
this.props.onToggleMultiple();
e.preventDefault();
e.stopPropagation();
};
+ onSuggestionsClearRequested = () => {
+ this.props.onClearSuggestions();
+ }
+
+ onSuggestionsFetchRequested = (token) => {
+ this.props.onFetchSuggestions(token);
+ }
+
+ onSuggestionSelected = (tokenStart, token, value) => {
+ this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
+ }
+
render () {
const { isPollMultiple, title, index, intl } = this.props;
@@ -57,12 +75,16 @@ class Option extends React.PureComponent {
tabIndex='0'
/>
-
@@ -87,6 +109,10 @@ class PollForm extends ImmutablePureComponent {
onAddOption: PropTypes.func.isRequired,
onRemoveOption: PropTypes.func.isRequired,
onChangeSettings: PropTypes.func.isRequired,
+ suggestions: ImmutablePropTypes.list,
+ onClearSuggestions: PropTypes.func.isRequired,
+ onFetchSuggestions: PropTypes.func.isRequired,
+ onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@@ -103,7 +129,7 @@ class PollForm extends ImmutablePureComponent {
};
render () {
- const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl } = this.props;
+ const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
if (!options) {
return null;
@@ -112,7 +138,7 @@ class PollForm extends ImmutablePureComponent {
return (
- {options.map((title, i) => )}
+ {options.map((title, i) => )}
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
index f9f1fba36..93a468388 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -45,8 +45,8 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(fetchComposeSuggestions(token));
},
- onSuggestionSelected (position, token, suggestion) {
- dispatch(selectComposeSuggestion(position, token, suggestion));
+ onSuggestionSelected (position, token, suggestion, path) {
+ dispatch(selectComposeSuggestion(position, token, suggestion, path));
},
onChangeSpoilerText (checked) {
diff --git a/app/javascript/mastodon/features/compose/containers/poll_form_container.js b/app/javascript/mastodon/features/compose/containers/poll_form_container.js
index da795a291..1401371d0 100644
--- a/app/javascript/mastodon/features/compose/containers/poll_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/poll_form_container.js
@@ -1,8 +1,14 @@
import { connect } from 'react-redux';
import PollForm from '../components/poll_form';
import { addPollOption, removePollOption, changePollOption, changePollSettings } from '../../../actions/compose';
+import {
+ clearComposeSuggestions,
+ fetchComposeSuggestions,
+ selectComposeSuggestion,
+} from '../../../actions/compose';
const mapStateToProps = state => ({
+ suggestions: state.getIn(['compose', 'suggestions']),
options: state.getIn(['compose', 'poll', 'options']),
expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
isMultiple: state.getIn(['compose', 'poll', 'multiple']),
@@ -24,6 +30,19 @@ const mapDispatchToProps = dispatch => ({
onChangeSettings(expiresIn, isMultiple) {
dispatch(changePollSettings(expiresIn, isMultiple));
},
+
+ onClearSuggestions () {
+ dispatch(clearComposeSuggestions());
+ },
+
+ onFetchSuggestions (token) {
+ dispatch(fetchComposeSuggestions(token));
+ },
+
+ onSuggestionSelected (position, token, accountId, path) {
+ dispatch(selectComposeSuggestion(position, token, accountId, path));
+ },
+
});
export default connect(mapStateToProps, mapDispatchToProps)(PollForm);
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index b45def281..39cc5bd81 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -131,13 +131,15 @@ function removeMedia(state, mediaId) {
});
};
-const insertSuggestion = (state, position, token, completion) => {
+const insertSuggestion = (state, position, token, completion, path) => {
return state.withMutations(map => {
- map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
+ map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
map.set('suggestion_token', null);
- map.update('suggestions', ImmutableList(), list => list.clear());
- map.set('focusDate', new Date());
- map.set('caretPosition', position + completion.length + 1);
+ map.set('suggestions', ImmutableList());
+ if (path.length === 1 && path[0] === 'text') {
+ map.set('focusDate', new Date());
+ map.set('caretPosition', position + completion.length + 1);
+ }
map.set('idempotencyKey', uuid());
});
};
@@ -304,7 +306,7 @@ export default function compose(state = initialState, action) {
case COMPOSE_SUGGESTIONS_READY:
return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
case COMPOSE_SUGGESTION_SELECT:
- return insertSuggestion(state, action.position, action.token, action.completion);
+ return insertSuggestion(state, action.position, action.token, action.completion, action.path);
case COMPOSE_SUGGESTION_TAGS_UPDATE:
return updateSuggestionTags(state, action.token);
case COMPOSE_TAG_HISTORY_UPDATE:
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 0da3ed909..e8c5f70f5 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -319,6 +319,7 @@
}
.autosuggest-textarea,
+ .autosuggest-input,
.spoiler-input {
position: relative;
}
diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss
index 37c454a78..0d55afda4 100644
--- a/app/javascript/styles/mastodon/polls.scss
+++ b/app/javascript/styles/mastodon/polls.scss
@@ -37,11 +37,14 @@
display: none;
}
+ .autossugest-input {
+ flex: 1 1 auto;
+ }
+
input[type=text] {
display: block;
box-sizing: border-box;
- flex: 1 1 auto;
- width: 20px;
+ width: 100%;
font-size: 14px;
color: $inverted-text-color;
display: block;
@@ -64,6 +67,7 @@
&.editable {
display: flex;
align-items: center;
+ overflow: visible;
}
}