Przeglądaj źródła

Use full-text search for autosuggestions

Eugen Rochko 7 lat temu
rodzic
commit
09218d4c01

+ 1 - 0
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'

+ 6 - 1
Gemfile.lock

@@ -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

+ 23 - 7
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
+    });
+  };
+};

+ 3 - 2
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>
     );

+ 11 - 0
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;

+ 15 - 0
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);

+ 31 - 22
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}

+ 8 - 2
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));
     }
   }
 };

+ 2 - 0
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:

+ 18 - 4
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);

+ 6 - 1
app/controllers/api/v1/accounts_controller.rb

@@ -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

+ 3 - 0
app/models/account.rb

@@ -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 - 0
app/services/search_service.rb

@@ -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

+ 1 - 0
config/routes.rb

@@ -81,6 +81,7 @@ Rails.application.routes.draw do
           get :relationships
           get :verify_credentials
           get :suggestions
+          get :search
         end
 
         member do