Use full-text search for autosuggestions
This commit is contained in:
		
							
								
								
									
										1
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Gemfile
									
									
									
									
									
								
							| @@ -42,6 +42,7 @@ gem 'rack-cors', require: 'rack/cors' | ||||
| gem 'sidekiq' | ||||
| gem 'ledermann-rails-settings' | ||||
| gem 'neography' | ||||
| gem 'pg_search' | ||||
|  | ||||
| gem 'react-rails' | ||||
| gem 'browserify-rails' | ||||
|   | ||||
| @@ -203,6 +203,10 @@ GEM | ||||
|     parser (2.3.1.2) | ||||
|       ast (~> 2.2) | ||||
|     pg (0.18.4) | ||||
|     pg_search (1.0.6) | ||||
|       activerecord (>= 3.1) | ||||
|       activesupport (>= 3.1) | ||||
|       arel | ||||
|     pghero (1.6.2) | ||||
|       activerecord | ||||
|     powerpack (0.1.1) | ||||
| @@ -410,6 +414,7 @@ DEPENDENCIES | ||||
|   paperclip (~> 4.3) | ||||
|   paperclip-av-transcoder | ||||
|   pg | ||||
|   pg_search | ||||
|   pghero | ||||
|   pry-rails | ||||
|   puma | ||||
| @@ -435,4 +440,4 @@ DEPENDENCIES | ||||
|   will_paginate | ||||
|  | ||||
| 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_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); | ||||
|   | ||||
| @@ -2,7 +2,7 @@ class Api::V1::AccountsController < ApiController | ||||
|   before_action -> { doorkeeper_authorize! :read }, except: [: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 :set_account, except: [:verify_credentials, :suggestions] | ||||
|   before_action :set_account, except: [:verify_credentials, :suggestions, :search] | ||||
|  | ||||
|   respond_to :json | ||||
|  | ||||
| @@ -91,6 +91,11 @@ class Api::V1::AccountsController < ApiController | ||||
|     @blocking    = Account.blocking_map(ids, current_user.account_id) | ||||
|   end | ||||
|  | ||||
|   def search | ||||
|     @accounts = SearchService.new.call(params[:q], params[:resolve] == 'true') | ||||
|     render action: :index | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_account | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| class Account < ApplicationRecord | ||||
|   include Targetable | ||||
|   include PgSearch | ||||
|  | ||||
|   MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i | ||||
|   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze | ||||
| @@ -42,6 +43,8 @@ class Account < ApplicationRecord | ||||
|  | ||||
|   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 :local, -> { where(domain: nil) } | ||||
|   scope :without_followers, -> { where('(select count(f.id) from follows as f where f.target_account_id = accounts.id) = 0') } | ||||
|   | ||||
							
								
								
									
										25
									
								
								app/services/search_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/services/search_service.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 :verify_credentials | ||||
|           get :suggestions | ||||
|           get :search | ||||
|         end | ||||
|  | ||||
|         member do | ||||
|   | ||||
		Reference in New Issue
	
	Block a user