From 09218d4c0152013750dd1c127d3c8267dc45f880 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 12 Nov 2016 14:33:21 +0100 Subject: [PATCH] Use full-text search for autosuggestions --- Gemfile | 1 + Gemfile.lock | 7 ++- .../components/actions/compose.jsx | 30 ++++++++--- .../components/components/avatar.jsx | 5 +- .../components/autosuggest_account.jsx | 11 ++++ .../autosuggest_account_container.jsx | 15 ++++++ .../features/ui/components/compose_form.jsx | 53 +++++++++++-------- .../ui/containers/compose_form_container.jsx | 10 +++- .../components/reducers/accounts.jsx | 2 + .../components/reducers/compose.jsx | 22 ++++++-- app/controllers/api/v1/accounts_controller.rb | 7 ++- app/models/account.rb | 3 ++ app/services/search_service.rb | 25 +++++++++ config/routes.rb | 1 + 14 files changed, 153 insertions(+), 39 deletions(-) create mode 100644 app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx create mode 100644 app/assets/javascripts/components/features/compose/containers/autosuggest_account_container.jsx create mode 100644 app/services/search_service.rb diff --git a/Gemfile b/Gemfile index a3d5cdd4555..37c7459c863 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index 56f9d34bcb7..9657ee212ad 100644 --- a/Gemfile.lock +++ b/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 diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index d343867dd85..c9be895f127 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 6177683ba71..687aa7bb91c 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 ( -
+
); 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 00000000000..9ea7f190fb4 --- /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 }) => ( +
+
+ +
+); + +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 00000000000..de76a364d06 --- /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 0655a7c79f9..20dc327094f 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 => ( - {suggestion.label} -); +const getSuggestionValue = suggestionId => suggestionId; +const renderSuggestion = 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} { 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 c6705d13ccb..471e1b0aa12 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 85799bf015d..3adff36a305 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); diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 5cc0817f630..9b02c998170 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/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 diff --git a/app/models/account.rb b/app/models/account.rb index 4fb0baebe88..19300f48b01 100644 --- a/app/models/account.rb +++ b/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') } diff --git a/app/services/search_service.rb b/app/services/search_service.rb new file mode 100644 index 00000000000..d9b6278531a --- /dev/null +++ b/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 diff --git a/config/routes.rb b/config/routes.rb index 5916ffa4215..a19ccac50ca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,7 @@ Rails.application.routes.draw do get :relationships get :verify_credentials get :suggestions + get :search end member do