@@ -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' | |||
@@ -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 |
@@ -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 | |||
}); | |||
}; | |||
}; |
@@ -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> | |||
); | |||
@@ -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 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} | |||
@@ -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)); | |||
} | |||
} | |||
}; | |||
@@ -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: | |||
@@ -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); | |||
@@ -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 | |||
@@ -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') } | |||
@@ -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 :verify_credentials | |||
get :suggestions | |||
get :search | |||
end | |||
member do | |||