mirror of https://github.com/Siphonay/mastodon
Use full-text search for autosuggestions
This commit is contained in:
parent
cd765f26a9
commit
09218d4c01
1
Gemfile
1
Gemfile
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 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}
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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') }
|
||||||
|
|
|
@ -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 :relationships
|
||||||
get :verify_credentials
|
get :verify_credentials
|
||||||
get :suggestions
|
get :suggestions
|
||||||
|
get :search
|
||||||
end
|
end
|
||||||
|
|
||||||
member do
|
member do
|
||||||
|
|
Loading…
Reference in New Issue