Use full-text search for autosuggestions
This commit is contained in:
		
							parent
							
								
									cd765f26a9
								
							
						
					
					
						commit
						09218d4c01
					
				
							
								
								
									
										1
									
								
								Gemfile
								
								
								
								
							
							
						
						
									
										1
									
								
								Gemfile
								
								
								
								
							| 
						 | 
					@ -42,6 +42,7 @@ gem 'rack-cors', require: 'rack/cors'
 | 
				
			||||||
gem 'sidekiq'
 | 
					gem 'sidekiq'
 | 
				
			||||||
gem 'ledermann-rails-settings'
 | 
					gem 'ledermann-rails-settings'
 | 
				
			||||||
gem 'neography'
 | 
					gem 'neography'
 | 
				
			||||||
 | 
					gem 'pg_search'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gem 'react-rails'
 | 
					gem 'react-rails'
 | 
				
			||||||
gem 'browserify-rails'
 | 
					gem 'browserify-rails'
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -203,6 +203,10 @@ GEM
 | 
				
			||||||
    parser (2.3.1.2)
 | 
					    parser (2.3.1.2)
 | 
				
			||||||
      ast (~> 2.2)
 | 
					      ast (~> 2.2)
 | 
				
			||||||
    pg (0.18.4)
 | 
					    pg (0.18.4)
 | 
				
			||||||
 | 
					    pg_search (1.0.6)
 | 
				
			||||||
 | 
					      activerecord (>= 3.1)
 | 
				
			||||||
 | 
					      activesupport (>= 3.1)
 | 
				
			||||||
 | 
					      arel
 | 
				
			||||||
    pghero (1.6.2)
 | 
					    pghero (1.6.2)
 | 
				
			||||||
      activerecord
 | 
					      activerecord
 | 
				
			||||||
    powerpack (0.1.1)
 | 
					    powerpack (0.1.1)
 | 
				
			||||||
| 
						 | 
					@ -410,6 +414,7 @@ DEPENDENCIES
 | 
				
			||||||
  paperclip (~> 4.3)
 | 
					  paperclip (~> 4.3)
 | 
				
			||||||
  paperclip-av-transcoder
 | 
					  paperclip-av-transcoder
 | 
				
			||||||
  pg
 | 
					  pg
 | 
				
			||||||
 | 
					  pg_search
 | 
				
			||||||
  pghero
 | 
					  pghero
 | 
				
			||||||
  pry-rails
 | 
					  pry-rails
 | 
				
			||||||
  puma
 | 
					  puma
 | 
				
			||||||
| 
						 | 
					@ -435,4 +440,4 @@ DEPENDENCIES
 | 
				
			||||||
  will_paginate
 | 
					  will_paginate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
BUNDLED WITH
 | 
					BUNDLED WITH
 | 
				
			||||||
   1.13.0
 | 
					   1.13.6
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,6 +17,7 @@ export const COMPOSE_UPLOAD_UNDO     = 'COMPOSE_UPLOAD_UNDO';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
 | 
					export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
 | 
				
			||||||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
 | 
					export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
 | 
				
			||||||
 | 
					export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function changeCompose(text) {
 | 
					export function changeCompose(text) {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
| 
						 | 
					@ -144,18 +145,33 @@ export function clearComposeSuggestions() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function fetchComposeSuggestions(token) {
 | 
					export function fetchComposeSuggestions(token) {
 | 
				
			||||||
  return (dispatch, getState) => {
 | 
					  return (dispatch, getState) => {
 | 
				
			||||||
    const loadedCandidates = getState().get('accounts').filter(item => item.get('acct').toLowerCase().slice(0, token.length) === token).map(item => ({
 | 
					    api(getState).get('/api/v1/accounts/search', {
 | 
				
			||||||
      label: item.get('acct'),
 | 
					      params: {
 | 
				
			||||||
      completion: item.get('acct').slice(token.length)
 | 
					        q: token,
 | 
				
			||||||
    })).toList().toJS();
 | 
					        resolve: false
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    dispatch(readyComposeSuggestions(loadedCandidates));
 | 
					    }).then(response => {
 | 
				
			||||||
 | 
					      dispatch(readyComposeSuggestions(token, response.data));
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function readyComposeSuggestions(accounts) {
 | 
					export function readyComposeSuggestions(token, accounts) {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    type: COMPOSE_SUGGESTIONS_READY,
 | 
					    type: COMPOSE_SUGGESTIONS_READY,
 | 
				
			||||||
 | 
					    token,
 | 
				
			||||||
    accounts
 | 
					    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: {
 | 
					  propTypes: {
 | 
				
			||||||
    src: React.PropTypes.string.isRequired,
 | 
					    src: React.PropTypes.string.isRequired,
 | 
				
			||||||
    size: React.PropTypes.number.isRequired
 | 
					    size: React.PropTypes.number.isRequired,
 | 
				
			||||||
 | 
					    style: React.PropTypes.object
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  mixins: [PureRenderMixin],
 | 
					  mixins: [PureRenderMixin],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    return (
 | 
					    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' }} />
 | 
					        <img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} />
 | 
				
			||||||
      </div>
 | 
					      </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 CharacterCounter from './character_counter';
 | 
				
			||||||
import Button             from '../../../components/button';
 | 
					import Button from '../../../components/button';
 | 
				
			||||||
import PureRenderMixin    from 'react-addons-pure-render-mixin';
 | 
					import PureRenderMixin from 'react-addons-pure-render-mixin';
 | 
				
			||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
import ReplyIndicator     from './reply_indicator';
 | 
					import ReplyIndicator from './reply_indicator';
 | 
				
			||||||
import UploadButton       from './upload_button';
 | 
					import UploadButton from './upload_button';
 | 
				
			||||||
import Autosuggest        from 'react-autosuggest';
 | 
					import Autosuggest from 'react-autosuggest';
 | 
				
			||||||
 | 
					import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getTokenForSuggestions = (str, caretPosition) => {
 | 
					const getTokenForSuggestions = (str, caretPosition) => {
 | 
				
			||||||
  let word;
 | 
					  let word;
 | 
				
			||||||
| 
						 | 
					@ -31,11 +32,8 @@ const getTokenForSuggestions = (str, caretPosition) => {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getSuggestionValue = suggestion => suggestion.completion;
 | 
					const getSuggestionValue = suggestionId => suggestionId;
 | 
				
			||||||
 | 
					const renderSuggestion   = suggestionId => <AutosuggestAccountContainer id={suggestionId} />;
 | 
				
			||||||
const renderSuggestion = suggestion => (
 | 
					 | 
				
			||||||
  <span>{suggestion.label}</span>
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const textareaStyle = {
 | 
					const textareaStyle = {
 | 
				
			||||||
  display: 'block',
 | 
					  display: 'block',
 | 
				
			||||||
| 
						 | 
					@ -59,18 +57,26 @@ const ComposeForm = React.createClass({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  propTypes: {
 | 
					  propTypes: {
 | 
				
			||||||
    text: React.PropTypes.string.isRequired,
 | 
					    text: React.PropTypes.string.isRequired,
 | 
				
			||||||
 | 
					    suggestion_token: React.PropTypes.string,
 | 
				
			||||||
    suggestions: React.PropTypes.array,
 | 
					    suggestions: React.PropTypes.array,
 | 
				
			||||||
    is_submitting: React.PropTypes.bool,
 | 
					    is_submitting: React.PropTypes.bool,
 | 
				
			||||||
    is_uploading: React.PropTypes.bool,
 | 
					    is_uploading: React.PropTypes.bool,
 | 
				
			||||||
    in_reply_to: ImmutablePropTypes.map,
 | 
					    in_reply_to: ImmutablePropTypes.map,
 | 
				
			||||||
    onChange: React.PropTypes.func.isRequired,
 | 
					    onChange: React.PropTypes.func.isRequired,
 | 
				
			||||||
    onSubmit: 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],
 | 
					  mixins: [PureRenderMixin],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleChange (e) {
 | 
					  handleChange (e) {
 | 
				
			||||||
 | 
					    if (typeof e.target.value === 'undefined' || typeof e.target.value === 'number') {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.props.onChange(e.target.value);
 | 
					    this.props.onChange(e.target.value);
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -86,8 +92,7 @@ const ComposeForm = React.createClass({
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentDidUpdate (prevProps) {
 | 
					  componentDidUpdate (prevProps) {
 | 
				
			||||||
    if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
 | 
					    if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
 | 
				
			||||||
      const node     = ReactDOM.findDOMNode(this.refs.autosuggest);
 | 
					      const textarea = this.autosuggest.input;
 | 
				
			||||||
      const textarea = node.querySelector('textarea');
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (textarea) {
 | 
					      if (textarea) {
 | 
				
			||||||
        textarea.focus();
 | 
					        textarea.focus();
 | 
				
			||||||
| 
						 | 
					@ -100,28 +105,31 @@ const ComposeForm = React.createClass({
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onSuggestionsFetchRequested ({ value }) {
 | 
					  onSuggestionsFetchRequested ({ value }) {
 | 
				
			||||||
    const node     = ReactDOM.findDOMNode(this.refs.autosuggest);
 | 
					    const textarea = this.autosuggest.input;
 | 
				
			||||||
    const textarea = node.querySelector('textarea');
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (textarea) {
 | 
					    if (textarea) {
 | 
				
			||||||
      const token = getTokenForSuggestions(value, textarea.selectionStart);
 | 
					      const token = getTokenForSuggestions(value, textarea.selectionStart);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (token !== null) {
 | 
					      if (token !== null) {
 | 
				
			||||||
        this.props.onFetchSuggestions(token);
 | 
					        this.props.onFetchSuggestions(token);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        this.props.onClearSuggestions();
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onSuggestionSelected (e, { suggestionValue, method }) {
 | 
					  onSuggestionSelected (e, { suggestionValue }) {
 | 
				
			||||||
    const node     = ReactDOM.findDOMNode(this.refs.autosuggest);
 | 
					    const textarea = this.autosuggest.input;
 | 
				
			||||||
    const textarea = node.querySelector('textarea');
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (textarea) {
 | 
					    if (textarea) {
 | 
				
			||||||
      const str = this.props.text;
 | 
					      this.props.onSuggestionSelected(textarea.selectionStart, suggestionValue);
 | 
				
			||||||
      this.props.onChange([str.slice(0, textarea.selectionStart), suggestionValue, str.slice(textarea.selectionStart)].join(''));
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setRef (c) {
 | 
				
			||||||
 | 
					    this.autosuggest = c;
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    let replyArea  = '';
 | 
					    let replyArea  = '';
 | 
				
			||||||
    const disabled = this.props.is_submitting || this.props.is_uploading;
 | 
					    const disabled = this.props.is_submitting || this.props.is_uploading;
 | 
				
			||||||
| 
						 | 
					@ -143,8 +151,9 @@ const ComposeForm = React.createClass({
 | 
				
			||||||
        {replyArea}
 | 
					        {replyArea}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <Autosuggest
 | 
					        <Autosuggest
 | 
				
			||||||
          ref='autosuggest'
 | 
					          ref={this.setRef}
 | 
				
			||||||
          suggestions={this.props.suggestions}
 | 
					          suggestions={this.props.suggestions}
 | 
				
			||||||
 | 
					          focusFirstSuggestion={true}
 | 
				
			||||||
          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
 | 
					          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
 | 
				
			||||||
          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
 | 
					          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
 | 
				
			||||||
          onSuggestionSelected={this.onSuggestionSelected}
 | 
					          onSuggestionSelected={this.onSuggestionSelected}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,7 +5,8 @@ import {
 | 
				
			||||||
  submitCompose,
 | 
					  submitCompose,
 | 
				
			||||||
  cancelReplyCompose,
 | 
					  cancelReplyCompose,
 | 
				
			||||||
  clearComposeSuggestions,
 | 
					  clearComposeSuggestions,
 | 
				
			||||||
  fetchComposeSuggestions
 | 
					  fetchComposeSuggestions,
 | 
				
			||||||
 | 
					  selectComposeSuggestion
 | 
				
			||||||
} from '../../../actions/compose';
 | 
					} from '../../../actions/compose';
 | 
				
			||||||
import { makeGetStatus } from '../../../selectors';
 | 
					import { makeGetStatus } from '../../../selectors';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -15,7 +16,8 @@ const makeMapStateToProps = () => {
 | 
				
			||||||
  const mapStateToProps = function (state, props) {
 | 
					  const mapStateToProps = function (state, props) {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      text: state.getIn(['compose', 'text']),
 | 
					      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_submitting: state.getIn(['compose', 'is_submitting']),
 | 
				
			||||||
      is_uploading: state.getIn(['compose', 'is_uploading']),
 | 
					      is_uploading: state.getIn(['compose', 'is_uploading']),
 | 
				
			||||||
      in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
 | 
					      in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
 | 
				
			||||||
| 
						 | 
					@ -45,6 +47,10 @@ const mapDispatchToProps = function (dispatch) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    onFetchSuggestions (token) {
 | 
					    onFetchSuggestions (token) {
 | 
				
			||||||
      dispatch(fetchComposeSuggestions(token));
 | 
					      dispatch(fetchComposeSuggestions(token));
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    onSuggestionSelected (position, accountId) {
 | 
				
			||||||
 | 
					      dispatch(selectComposeSuggestion(position, accountId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,6 +8,7 @@ import {
 | 
				
			||||||
} from '../actions/accounts';
 | 
					} from '../actions/accounts';
 | 
				
			||||||
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
 | 
					import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
 | 
				
			||||||
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
 | 
					import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
 | 
				
			||||||
 | 
					import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  REBLOG_SUCCESS,
 | 
					  REBLOG_SUCCESS,
 | 
				
			||||||
  UNREBLOG_SUCCESS,
 | 
					  UNREBLOG_SUCCESS,
 | 
				
			||||||
| 
						 | 
					@ -68,6 +69,7 @@ export default function accounts(state = initialState, action) {
 | 
				
			||||||
    case FOLLOWING_FETCH_SUCCESS:
 | 
					    case FOLLOWING_FETCH_SUCCESS:
 | 
				
			||||||
    case REBLOGS_FETCH_SUCCESS:
 | 
					    case REBLOGS_FETCH_SUCCESS:
 | 
				
			||||||
    case FAVOURITES_FETCH_SUCCESS:
 | 
					    case FAVOURITES_FETCH_SUCCESS:
 | 
				
			||||||
 | 
					    case COMPOSE_SUGGESTIONS_READY:
 | 
				
			||||||
      return normalizeAccounts(state, action.accounts);
 | 
					      return normalizeAccounts(state, action.accounts);
 | 
				
			||||||
    case TIMELINE_REFRESH_SUCCESS:
 | 
					    case TIMELINE_REFRESH_SUCCESS:
 | 
				
			||||||
    case TIMELINE_EXPAND_SUCCESS:
 | 
					    case TIMELINE_EXPAND_SUCCESS:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,7 +12,8 @@ import {
 | 
				
			||||||
  COMPOSE_UPLOAD_UNDO,
 | 
					  COMPOSE_UPLOAD_UNDO,
 | 
				
			||||||
  COMPOSE_UPLOAD_PROGRESS,
 | 
					  COMPOSE_UPLOAD_PROGRESS,
 | 
				
			||||||
  COMPOSE_SUGGESTIONS_CLEAR,
 | 
					  COMPOSE_SUGGESTIONS_CLEAR,
 | 
				
			||||||
  COMPOSE_SUGGESTIONS_READY
 | 
					  COMPOSE_SUGGESTIONS_READY,
 | 
				
			||||||
 | 
					  COMPOSE_SUGGESTION_SELECT
 | 
				
			||||||
} from '../actions/compose';
 | 
					} from '../actions/compose';
 | 
				
			||||||
import { TIMELINE_DELETE } from '../actions/timelines';
 | 
					import { TIMELINE_DELETE } from '../actions/timelines';
 | 
				
			||||||
import { ACCOUNT_SET_SELF } from '../actions/accounts';
 | 
					import { ACCOUNT_SET_SELF } from '../actions/accounts';
 | 
				
			||||||
| 
						 | 
					@ -25,7 +26,8 @@ const initialState = Immutable.Map({
 | 
				
			||||||
  is_uploading: false,
 | 
					  is_uploading: false,
 | 
				
			||||||
  progress: 0,
 | 
					  progress: 0,
 | 
				
			||||||
  media_attachments: Immutable.List(),
 | 
					  media_attachments: Immutable.List(),
 | 
				
			||||||
  suggestions: [],
 | 
					  suggestion_token: null,
 | 
				
			||||||
 | 
					  suggestions: Immutable.List(),
 | 
				
			||||||
  me: null
 | 
					  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) {
 | 
					export default function compose(state = initialState, action) {
 | 
				
			||||||
  switch(action.type) {
 | 
					  switch(action.type) {
 | 
				
			||||||
    case COMPOSE_CHANGE:
 | 
					    case COMPOSE_CHANGE:
 | 
				
			||||||
| 
						 | 
					@ -99,9 +111,11 @@ export default function compose(state = initialState, action) {
 | 
				
			||||||
    case COMPOSE_MENTION:
 | 
					    case COMPOSE_MENTION:
 | 
				
			||||||
      return state.update('text', text => `${text}@${action.account.get('acct')} `);
 | 
					      return state.update('text', text => `${text}@${action.account.get('acct')} `);
 | 
				
			||||||
    case COMPOSE_SUGGESTIONS_CLEAR:
 | 
					    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:
 | 
					    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:
 | 
					    case TIMELINE_DELETE:
 | 
				
			||||||
      if (action.id === state.get('in_reply_to')) {
 | 
					      if (action.id === state.get('in_reply_to')) {
 | 
				
			||||||
        return state.set('in_reply_to', null);
 | 
					        return state.set('in_reply_to', null);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,7 +2,7 @@ class Api::V1::AccountsController < ApiController
 | 
				
			||||||
  before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock]
 | 
					  before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock]
 | 
				
			||||||
  before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock]
 | 
					  before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock]
 | 
				
			||||||
  before_action :require_user!, except: [:show, :following, :followers, :statuses]
 | 
					  before_action :require_user!, except: [:show, :following, :followers, :statuses]
 | 
				
			||||||
  before_action :set_account, except: [:verify_credentials, :suggestions]
 | 
					  before_action :set_account, except: [:verify_credentials, :suggestions, :search]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  respond_to :json
 | 
					  respond_to :json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -91,6 +91,11 @@ class Api::V1::AccountsController < ApiController
 | 
				
			||||||
    @blocking    = Account.blocking_map(ids, current_user.account_id)
 | 
					    @blocking    = Account.blocking_map(ids, current_user.account_id)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def search
 | 
				
			||||||
 | 
					    @accounts = SearchService.new.call(params[:q], params[:resolve] == 'true')
 | 
				
			||||||
 | 
					    render action: :index
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def set_account
 | 
					  def set_account
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,6 @@
 | 
				
			||||||
class Account < ApplicationRecord
 | 
					class Account < ApplicationRecord
 | 
				
			||||||
  include Targetable
 | 
					  include Targetable
 | 
				
			||||||
 | 
					  include PgSearch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
 | 
					  MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
 | 
				
			||||||
  IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
 | 
					  IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
 | 
				
			||||||
| 
						 | 
					@ -42,6 +43,8 @@ class Account < ApplicationRecord
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  has_many :media_attachments, dependent: :destroy
 | 
					  has_many :media_attachments, dependent: :destroy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  pg_search_scope :search_for, against: %i(username domain), using: { tsearch: { prefix: true } }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  scope :remote, -> { where.not(domain: nil) }
 | 
					  scope :remote, -> { where.not(domain: nil) }
 | 
				
			||||||
  scope :local, -> { where(domain: nil) }
 | 
					  scope :local, -> { where(domain: nil) }
 | 
				
			||||||
  scope :without_followers, -> { where('(select count(f.id) from follows as f where f.target_account_id = accounts.id) = 0') }
 | 
					  scope :without_followers, -> { where('(select count(f.id) from follows as f where f.target_account_id = accounts.id) = 0') }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,25 @@
 | 
				
			||||||
 | 
					class SearchService < BaseService
 | 
				
			||||||
 | 
					  def call(query, resolve = false)
 | 
				
			||||||
 | 
					    return if query.blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    username, domain = query.split('@')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if domain.nil?
 | 
				
			||||||
 | 
					      search_all(username)
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
 | 
					      search_or_resolve(username, domain, resolve)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def search_all(username)
 | 
				
			||||||
 | 
					    Account.search_for(username)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def search_or_resolve(username, domain, resolve)
 | 
				
			||||||
 | 
					    results = Account.search_for("#{username} #{domain}")
 | 
				
			||||||
 | 
					    return [FollowRemoteAccountService.new.call("#{username}@#{domain}")] if results.empty? && resolve
 | 
				
			||||||
 | 
					    results
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
| 
						 | 
					@ -81,6 +81,7 @@ Rails.application.routes.draw do
 | 
				
			||||||
          get :relationships
 | 
					          get :relationships
 | 
				
			||||||
          get :verify_credentials
 | 
					          get :verify_credentials
 | 
				
			||||||
          get :suggestions
 | 
					          get :suggestions
 | 
				
			||||||
 | 
					          get :search
 | 
				
			||||||
        end
 | 
					        end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        member do
 | 
					        member do
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue