diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2016-11-12 14:33:21 +0100 |
---|---|---|
committer | Eugen Rochko <eugen@zeonfederated.com> | 2016-11-12 14:36:10 +0100 |
commit | 09218d4c0152013750dd1c127d3c8267dc45f880 (patch) | |
tree | 7cd9975b84a28de92403c80f483d08e471b10155 /app/assets | |
parent | cd765f26a9610e160ffd347637fca40d7b80164e (diff) |
Use full-text search for autosuggestions
Diffstat (limited to 'app/assets')
8 files changed, 111 insertions, 37 deletions
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index d343867dd..c9be895f1 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -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 + }); + }; +}; diff --git a/app/assets/javascripts/components/components/avatar.jsx b/app/assets/javascripts/components/components/avatar.jsx index 6177683ba..687aa7bb9 100644 --- a/app/assets/javascripts/components/components/avatar.jsx +++ b/app/assets/javascripts/components/components/avatar.jsx @@ -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> ); diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx new file mode 100644 index 000000000..9ea7f190f --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx @@ -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; diff --git a/app/assets/javascripts/components/features/compose/containers/autosuggest_account_container.jsx b/app/assets/javascripts/components/features/compose/containers/autosuggest_account_container.jsx new file mode 100644 index 000000000..de76a364d --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/autosuggest_account_container.jsx @@ -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); diff --git a/app/assets/javascripts/components/features/ui/components/compose_form.jsx b/app/assets/javascripts/components/features/ui/components/compose_form.jsx index 0655a7c79..20dc32709 100644 --- a/app/assets/javascripts/components/features/ui/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/ui/components/compose_form.jsx @@ -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} diff --git a/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx index dcfeef752..87bcd6b99 100644 --- a/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/compose_form_container.jsx @@ -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)); } } }; diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index c6705d13c..471e1b0aa 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -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: diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 85799bf01..3adff36a3 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -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); |