Skip to content
Snippets Groups Projects
Commit c49f6290 authored by Eugen Rochko's avatar Eugen Rochko
Browse files

Basic username autocomplete for text area

parent fa1cc2d0
No related branches found
No related tags found
No related merge requests found
......@@ -13,6 +13,9 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
export function changeCompose(text) {
return {
type: COMPOSE_CHANGE,
......@@ -129,3 +132,27 @@ export function undoUploadCompose(media_id) {
media_id: media_id
};
};
export function clearComposeSuggestions() {
return {
type: COMPOSE_SUGGESTIONS_CLEAR
};
};
export function fetchComposeSuggestions(token) {
return (dispatch, getState) => {
const loadedCandidates = getState().get('accounts').filter(item => item.get('acct').toLowerCase().slice(0, token.length) === token).map(item => ({
label: item.get('acct'),
completion: item.get('acct').slice(0, token.length)
})).toList().toJS();
dispatch(readyComposeSuggestions(loadedCandidates));
};
};
export function readyComposeSuggestions(accounts) {
return {
type: COMPOSE_SUGGESTIONS_READY,
accounts
};
};
......@@ -4,11 +4,62 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ReplyIndicator from './reply_indicator';
import UploadButton from './upload_button';
import Autosuggest from 'react-autosuggest';
const getTokenForSuggestions = (str, caretPosition) => {
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 < 2 || word[0] !== '@') {
return null;
}
word = word.trim().toLowerCase().slice(1);
if (word.length > 0) {
return word;
} else {
return null;
}
};
const getSuggestionValue = suggestion => suggestion;
const renderSuggestion = suggestion => (
<span>{suggestion}</span>
);
const textareaStyle = {
display: 'block',
boxSizing: 'border-box',
width: '100%',
height: '100px',
resize: 'none',
border: 'none',
color: '#282c37',
padding: '10px',
fontFamily: 'Roboto',
fontSize: '14px',
margin: '0'
};
const renderInputComponent = inputProps => (
<textarea {...inputProps} placeholder='What is on your mind?' className='compose-form__textarea' style={textareaStyle} />
);
const ComposeForm = React.createClass({
propTypes: {
text: React.PropTypes.string.isRequired,
suggestions: React.PropTypes.array,
is_submitting: React.PropTypes.bool,
is_uploading: React.PropTypes.bool,
in_reply_to: ImmutablePropTypes.map,
......@@ -35,7 +86,39 @@ const ComposeForm = React.createClass({
componentDidUpdate (prevProps) {
if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
this.refs.textarea.focus();
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
const textarea = node.querySelector('textarea');
if (textarea) {
textarea.focus();
}
}
},
onSuggestionsClearRequested () {
this.props.onClearSuggestions();
},
onSuggestionsFetchRequested ({ value }) {
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
const textarea = node.querySelector('textarea');
if (textarea) {
const token = getTokenForSuggestions(value, textarea.selectionStart);
if (token !== null) {
this.props.onFetchSuggestions(token);
}
}
},
onSuggestionSelected (e, { suggestionValue, method }) {
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
const textarea = node.querySelector('textarea');
if (textarea) {
const str = this.props.text;
this.props.onChange([str.slice(0, textarea.selectionStart), suggestionValue, str.slice(textarea.selectionStart)].join(''));
}
},
......@@ -47,11 +130,29 @@ const ComposeForm = React.createClass({
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
}
const inputProps = {
placeholder: 'What is on your mind?',
value: this.props.text,
onKeyUp: this.handleKeyUp,
onChange: this.handleChange,
disabled: disabled
};
return (
<div style={{ padding: '10px' }}>
{replyArea}
<textarea ref='textarea' disabled={disabled} placeholder='What is on your mind?' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='compose-form__textarea' style={{ display: 'block', boxSizing: 'border-box', width: '100%', height: '100px', resize: 'none', border: 'none', color: '#282c37', padding: '10px', fontFamily: 'Roboto', fontSize: '14px', margin: '0' }} />
<Autosuggest
ref='autosuggest'
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
renderInputComponent={renderInputComponent}
inputProps={inputProps}
/>
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div>
......
......@@ -24,7 +24,7 @@ const UploadButton = React.createClass({
return (
<div>
<Button disabled={this.props.disabled} onClick={this.handleClick} block={true}>
<i className='fa fa-fw fa-photo' /> Add images
<i className='fa fa-fw fa-photo' /> Add media
</Button>
<input ref='fileElement' type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
......
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import {
changeCompose,
submitCompose,
cancelReplyCompose,
clearComposeSuggestions,
fetchComposeSuggestions
} from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
......@@ -9,6 +15,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = function (state, props) {
return {
text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
......@@ -20,16 +27,24 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = function (dispatch) {
return {
onChange: function (text) {
onChange (text) {
dispatch(changeCompose(text));
},
onSubmit: function () {
onSubmit () {
dispatch(submitCompose());
},
onCancelReply: function () {
onCancelReply () {
dispatch(cancelReplyCompose());
},
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions (token) {
dispatch(fetchComposeSuggestions(token));
}
}
};
......
......@@ -10,7 +10,9 @@ import {
COMPOSE_UPLOAD_SUCCESS,
COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_UNDO,
COMPOSE_UPLOAD_PROGRESS
COMPOSE_UPLOAD_PROGRESS,
COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import { ACCOUNT_SET_SELF } from '../actions/accounts';
......@@ -22,7 +24,8 @@ const initialState = Immutable.Map({
is_submitting: false,
is_uploading: false,
progress: 0,
media_attachments: Immutable.List([]),
media_attachments: Immutable.List(),
suggestions: [],
me: null
});
......@@ -95,6 +98,10 @@ export default function compose(state = initialState, action) {
return state.set('progress', Math.round((action.loaded / action.total) * 100));
case COMPOSE_MENTION:
return state.update('text', text => `${text}@${action.account.get('acct')} `);
case COMPOSE_SUGGESTIONS_CLEAR:
return state.set('suggestions', []);
case COMPOSE_SUGGESTIONS_READY:
return state.set('suggestions', action.accounts);
case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);
......
......@@ -266,3 +266,31 @@
flex-direction: column;
}
}
.react-autosuggest__container {
position: relative;
}
.react-autosuggest__suggestions-container {
position: absolute;
top: 100%;
width: 100%;
z-index: 99;
}
.react-autosuggest__suggestions-list {
background: #9baec8;
color: #282c37;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
font-size: 14px;
}
.react-autosuggest__suggestion {
padding: 10px;
cursor: pointer;
}
.react-autosuggest__suggestion--focused {
background: #2b90d9;
color: #fff;
}
......@@ -41,6 +41,7 @@
"sinon": "^1.17.6"
},
"dependencies": {
"react-autosuggest": "^7.0.1",
"react-responsive": "^1.1.5",
"react-router-scroll": "^0.3.2",
"react-skylight": "^0.4.1"
......
......@@ -3243,6 +3243,10 @@ oauth-sign@~0.8.1:
version "0.8.2"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
object-assign@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2"
object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0"
......@@ -3807,6 +3811,22 @@ react-addons-pure-render-mixin@^15.3.1:
version "15.3.2"
resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.3.2.tgz#c09a44f583425a4a9c1b38444d7a6c3e6f0f41f6"
react-autosuggest:
version "7.0.1"
resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-7.0.1.tgz#e751d2c2e516a344f6cdc150672e85f134f5f2f1"
dependencies:
react-autowhatever "^7.0.0"
react-redux "^4.4.5"
redux "^3.6.0"
shallow-equal "^1.0.0"
react-autowhatever@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-7.0.0.tgz#7ea19f8024183acf1568fc8e4b76c0d0cc250d00"
dependencies:
react-themeable "^1.1.0"
section-iterator "^2.0.0"
react-deep-force-update@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-1.0.1.tgz#f911b5be1d2a6fe387507dd6e9a767aa2924b4c7"
......@@ -3878,6 +3898,15 @@ react-redux-loading-bar@^2.3.3:
version "2.4.0"
resolved "https://registry.yarnpkg.com/react-redux-loading-bar/-/react-redux-loading-bar-2.4.0.tgz#00cd884c7ea8e0146fb94aeb1435b1a0caffd888"
react-redux@^4.4.5:
version "4.4.5"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-4.4.5.tgz#f509a2981be2252d10c629ef7c559347a4aec457"
dependencies:
hoist-non-react-statics "^1.0.3"
invariant "^2.0.0"
lodash "^4.2.0"
loose-envify "^1.1.0"
react-redux@^5.0.0-beta.3:
version "5.0.0-beta.3"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.0-beta.3.tgz#d50bfb00799cf7d2a9fd55fe34d6b3ecc24d3072"
......@@ -3931,6 +3960,12 @@ react-skylight:
version "0.4.1"
resolved "https://registry.yarnpkg.com/react-skylight/-/react-skylight-0.4.1.tgz#07d1af6dea0a50a5d8122a786a8ce8bc6bdf2241"
react-themeable@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e"
dependencies:
object-assign "^3.0.0"
react@^15.3.2:
version "15.3.2"
resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e"
......@@ -4033,7 +4068,7 @@ redux-thunk@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.1.0.tgz#c724bfee75dbe352da2e3ba9bc14302badd89a98"
redux@^3.5.2:
redux@^3.5.2, redux@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/redux/-/redux-3.6.0.tgz#887c2b3d0b9bd86eca2be70571c27654c19e188d"
dependencies:
......@@ -4170,6 +4205,10 @@ scroll-behavior@^0.8.0:
dom-helpers "^2.4.0"
invariant "^2.2.1"
section-iterator@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a"
semver@~5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"
......@@ -4232,6 +4271,10 @@ sha.js@2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.2.6.tgz#17ddeddc5f722fb66501658895461977867315ba"
shallow-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7"
shallowequal@0.2.x:
version "0.2.2"
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-0.2.2.tgz#1e32fd5bcab6ad688a4812cb0cc04efc75c7014e"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment