Use full-text search for autosuggestions
This commit is contained in:
		| @ -17,6 +17,7 @@ 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 const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; | ||||
|  | ||||
| export function changeCompose(text) { | ||||
|   return { | ||||
| @ -144,18 +145,33 @@ export function clearComposeSuggestions() { | ||||
|  | ||||
| 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(token.length) | ||||
|     })).toList().toJS(); | ||||
|  | ||||
|     dispatch(readyComposeSuggestions(loadedCandidates)); | ||||
|     api(getState).get('/api/v1/accounts/search', { | ||||
|       params: { | ||||
|         q: token, | ||||
|         resolve: false | ||||
|       } | ||||
|     }).then(response => { | ||||
|       dispatch(readyComposeSuggestions(token, response.data)); | ||||
|     }); | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function readyComposeSuggestions(accounts) { | ||||
| export function readyComposeSuggestions(token, accounts) { | ||||
|   return { | ||||
|     type: COMPOSE_SUGGESTIONS_READY, | ||||
|     token, | ||||
|     accounts | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function selectComposeSuggestion(position, accountId) { | ||||
|   return (dispatch, getState) => { | ||||
|     const completion = getState().getIn(['accounts', accountId, 'acct']); | ||||
|  | ||||
|     dispatch({ | ||||
|       type: COMPOSE_SUGGESTION_SELECT, | ||||
|       position, | ||||
|       completion | ||||
|     }); | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| @ -4,14 +4,15 @@ const Avatar = React.createClass({ | ||||
|  | ||||
|   propTypes: { | ||||
|     src: React.PropTypes.string.isRequired, | ||||
|     size: React.PropTypes.number.isRequired | ||||
|     size: React.PropTypes.number.isRequired, | ||||
|     style: React.PropTypes.object | ||||
|   }, | ||||
|  | ||||
|   mixins: [PureRenderMixin], | ||||
|  | ||||
|   render () { | ||||
|     return ( | ||||
|       <div style={{ width: `${this.props.size}px`, height: `${this.props.size}px` }}> | ||||
|       <div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}> | ||||
|         <img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} /> | ||||
|       </div> | ||||
|     ); | ||||
|  | ||||
| @ -0,0 +1,11 @@ | ||||
| import Avatar from '../../../components/avatar'; | ||||
| import DisplayName from '../../../components/display_name'; | ||||
|  | ||||
| const AutosuggestAccount = ({ account }) => ( | ||||
|   <div style={{ overflow: 'hidden' }}> | ||||
|     <div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div> | ||||
|     <DisplayName account={account} /> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| export default AutosuggestAccount; | ||||
| @ -0,0 +1,15 @@ | ||||
| import { connect } from 'react-redux'; | ||||
| import AutosuggestAccount from '../components/autosuggest_account'; | ||||
| import { makeGetAccount } from '../../../selectors'; | ||||
|  | ||||
| const makeMapStateToProps = () => { | ||||
|   const getAccount = makeGetAccount(); | ||||
|  | ||||
|   const mapStateToProps = (state, { id }) => ({ | ||||
|     account: getAccount(state, id) | ||||
|   }); | ||||
|  | ||||
|   return mapStateToProps; | ||||
| }; | ||||
|  | ||||
| export default connect(makeMapStateToProps)(AutosuggestAccount); | ||||
| @ -1,10 +1,11 @@ | ||||
| import CharacterCounter   from './character_counter'; | ||||
| import Button             from '../../../components/button'; | ||||
| import PureRenderMixin    from 'react-addons-pure-render-mixin'; | ||||
| import CharacterCounter from './character_counter'; | ||||
| import Button from '../../../components/button'; | ||||
| 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'; | ||||
| import ReplyIndicator from './reply_indicator'; | ||||
| import UploadButton from './upload_button'; | ||||
| import Autosuggest from 'react-autosuggest'; | ||||
| import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container'; | ||||
|  | ||||
| const getTokenForSuggestions = (str, caretPosition) => { | ||||
|   let word; | ||||
| @ -31,11 +32,8 @@ const getTokenForSuggestions = (str, caretPosition) => { | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const getSuggestionValue = suggestion => suggestion.completion; | ||||
|  | ||||
| const renderSuggestion = suggestion => ( | ||||
|   <span>{suggestion.label}</span> | ||||
| ); | ||||
| const getSuggestionValue = suggestionId => suggestionId; | ||||
| const renderSuggestion   = suggestionId => <AutosuggestAccountContainer id={suggestionId} />; | ||||
|  | ||||
| const textareaStyle = { | ||||
|   display: 'block', | ||||
| @ -59,18 +57,26 @@ const ComposeForm = React.createClass({ | ||||
|  | ||||
|   propTypes: { | ||||
|     text: React.PropTypes.string.isRequired, | ||||
|     suggestion_token: React.PropTypes.string, | ||||
|     suggestions: React.PropTypes.array, | ||||
|     is_submitting: React.PropTypes.bool, | ||||
|     is_uploading: React.PropTypes.bool, | ||||
|     in_reply_to: ImmutablePropTypes.map, | ||||
|     onChange: React.PropTypes.func.isRequired, | ||||
|     onSubmit: React.PropTypes.func.isRequired, | ||||
|     onCancelReply: React.PropTypes.func.isRequired | ||||
|     onCancelReply: React.PropTypes.func.isRequired, | ||||
|     onClearSuggestions: React.PropTypes.func.isRequired, | ||||
|     onFetchSuggestions: React.PropTypes.func.isRequired, | ||||
|     onSuggestionSelected: React.PropTypes.func.isRequired | ||||
|   }, | ||||
|  | ||||
|   mixins: [PureRenderMixin], | ||||
|  | ||||
|   handleChange (e) { | ||||
|     if (typeof e.target.value === 'undefined' || typeof e.target.value === 'number') { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this.props.onChange(e.target.value); | ||||
|   }, | ||||
|  | ||||
| @ -86,8 +92,7 @@ const ComposeForm = React.createClass({ | ||||
|  | ||||
|   componentDidUpdate (prevProps) { | ||||
|     if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) { | ||||
|       const node     = ReactDOM.findDOMNode(this.refs.autosuggest); | ||||
|       const textarea = node.querySelector('textarea'); | ||||
|       const textarea = this.autosuggest.input; | ||||
|  | ||||
|       if (textarea) { | ||||
|         textarea.focus(); | ||||
| @ -100,28 +105,31 @@ const ComposeForm = React.createClass({ | ||||
|   }, | ||||
|  | ||||
|   onSuggestionsFetchRequested ({ value }) { | ||||
|     const node     = ReactDOM.findDOMNode(this.refs.autosuggest); | ||||
|     const textarea = node.querySelector('textarea'); | ||||
|     const textarea = this.autosuggest.input; | ||||
|  | ||||
|     if (textarea) { | ||||
|       const token = getTokenForSuggestions(value, textarea.selectionStart); | ||||
|  | ||||
|       if (token !== null) { | ||||
|         this.props.onFetchSuggestions(token); | ||||
|       } else { | ||||
|         this.props.onClearSuggestions(); | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   onSuggestionSelected (e, { suggestionValue, method }) { | ||||
|     const node     = ReactDOM.findDOMNode(this.refs.autosuggest); | ||||
|     const textarea = node.querySelector('textarea'); | ||||
|   onSuggestionSelected (e, { suggestionValue }) { | ||||
|     const textarea = this.autosuggest.input; | ||||
|  | ||||
|     if (textarea) { | ||||
|       const str = this.props.text; | ||||
|       this.props.onChange([str.slice(0, textarea.selectionStart), suggestionValue, str.slice(textarea.selectionStart)].join('')); | ||||
|       this.props.onSuggestionSelected(textarea.selectionStart, suggestionValue); | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   setRef (c) { | ||||
|     this.autosuggest = c; | ||||
|   }, | ||||
|  | ||||
|   render () { | ||||
|     let replyArea  = ''; | ||||
|     const disabled = this.props.is_submitting || this.props.is_uploading; | ||||
| @ -143,8 +151,9 @@ const ComposeForm = React.createClass({ | ||||
|         {replyArea} | ||||
|  | ||||
|         <Autosuggest | ||||
|           ref='autosuggest' | ||||
|           ref={this.setRef} | ||||
|           suggestions={this.props.suggestions} | ||||
|           focusFirstSuggestion={true} | ||||
|           onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} | ||||
|           onSuggestionsClearRequested={this.onSuggestionsClearRequested} | ||||
|           onSuggestionSelected={this.onSuggestionSelected} | ||||
|  | ||||
| @ -5,7 +5,8 @@ import { | ||||
|   submitCompose, | ||||
|   cancelReplyCompose, | ||||
|   clearComposeSuggestions, | ||||
|   fetchComposeSuggestions | ||||
|   fetchComposeSuggestions, | ||||
|   selectComposeSuggestion | ||||
| } from '../../../actions/compose'; | ||||
| import { makeGetStatus } from '../../../selectors'; | ||||
|  | ||||
| @ -15,7 +16,8 @@ const makeMapStateToProps = () => { | ||||
|   const mapStateToProps = function (state, props) { | ||||
|     return { | ||||
|       text: state.getIn(['compose', 'text']), | ||||
|       suggestions: state.getIn(['compose', 'suggestions']), | ||||
|       suggestion_token: state.getIn(['compose', 'suggestion_token']), | ||||
|       suggestions: state.getIn(['compose', 'suggestions']).toJS(), | ||||
|       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'])) | ||||
| @ -45,6 +47,10 @@ const mapDispatchToProps = function (dispatch) { | ||||
|  | ||||
|     onFetchSuggestions (token) { | ||||
|       dispatch(fetchComposeSuggestions(token)); | ||||
|     }, | ||||
|  | ||||
|     onSuggestionSelected (position, accountId) { | ||||
|       dispatch(selectComposeSuggestion(position, accountId)); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -8,6 +8,7 @@ import { | ||||
| } from '../actions/accounts'; | ||||
| import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow'; | ||||
| import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions'; | ||||
| import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; | ||||
| import { | ||||
|   REBLOG_SUCCESS, | ||||
|   UNREBLOG_SUCCESS, | ||||
| @ -68,6 +69,7 @@ export default function accounts(state = initialState, action) { | ||||
|     case FOLLOWING_FETCH_SUCCESS: | ||||
|     case REBLOGS_FETCH_SUCCESS: | ||||
|     case FAVOURITES_FETCH_SUCCESS: | ||||
|     case COMPOSE_SUGGESTIONS_READY: | ||||
|       return normalizeAccounts(state, action.accounts); | ||||
|     case TIMELINE_REFRESH_SUCCESS: | ||||
|     case TIMELINE_EXPAND_SUCCESS: | ||||
|  | ||||
| @ -12,7 +12,8 @@ import { | ||||
|   COMPOSE_UPLOAD_UNDO, | ||||
|   COMPOSE_UPLOAD_PROGRESS, | ||||
|   COMPOSE_SUGGESTIONS_CLEAR, | ||||
|   COMPOSE_SUGGESTIONS_READY | ||||
|   COMPOSE_SUGGESTIONS_READY, | ||||
|   COMPOSE_SUGGESTION_SELECT | ||||
| } from '../actions/compose'; | ||||
| import { TIMELINE_DELETE } from '../actions/timelines'; | ||||
| import { ACCOUNT_SET_SELF } from '../actions/accounts'; | ||||
| @ -25,7 +26,8 @@ const initialState = Immutable.Map({ | ||||
|   is_uploading: false, | ||||
|   progress: 0, | ||||
|   media_attachments: Immutable.List(), | ||||
|   suggestions: [], | ||||
|   suggestion_token: null, | ||||
|   suggestions: Immutable.List(), | ||||
|   me: null | ||||
| }); | ||||
|  | ||||
| @ -66,6 +68,16 @@ function removeMedia(state, mediaId) { | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const insertSuggestion = (state, position, completion) => { | ||||
|   const token = state.get('suggestion_token'); | ||||
|  | ||||
|   return state.withMutations(map => { | ||||
|     map.update('text', oldText => `${oldText.slice(0, position - token.length)}${completion}${oldText.slice(position + token.length)}`); | ||||
|     map.set('suggestion_token', null); | ||||
|     map.update('suggestions', Immutable.List(), list => list.clear()); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export default function compose(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|     case COMPOSE_CHANGE: | ||||
| @ -99,9 +111,11 @@ export default function compose(state = initialState, action) { | ||||
|     case COMPOSE_MENTION: | ||||
|       return state.update('text', text => `${text}@${action.account.get('acct')} `); | ||||
|     case COMPOSE_SUGGESTIONS_CLEAR: | ||||
|       return state.set('suggestions', []); | ||||
|       return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); | ||||
|     case COMPOSE_SUGGESTIONS_READY: | ||||
|       return state.set('suggestions', action.accounts); | ||||
|       return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token); | ||||
|     case COMPOSE_SUGGESTION_SELECT: | ||||
|       return insertSuggestion(state, action.position, action.completion); | ||||
|     case TIMELINE_DELETE: | ||||
|       if (action.id === state.get('in_reply_to')) { | ||||
|         return state.set('in_reply_to', null); | ||||
|  | ||||
		Reference in New Issue
	
	Block a user