Use full-text search for autosuggestions

This commit is contained in:
Eugen Rochko 2016-11-12 14:33:21 +01:00
parent a6cd0711b0
commit cbfa28b9cc
14 changed files with 153 additions and 39 deletions

View file

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

View file

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

View file

@ -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
});
};
};

View file

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

View file

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

View file

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

View file

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

View file

@ -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));
} }
} }
}; };

View file

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

View file

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

View file

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

View file

@ -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') }

View 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

View file

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