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