Merge branch 'feature-omnisearch'
This commit is contained in:
		
						commit
						08faeedff7
					
				|  | @ -18,11 +18,13 @@ export function clearSearchSuggestions() { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function readySearchSuggestions(value, accounts) { | export function readySearchSuggestions(value, { accounts, hashtags, statuses }) { | ||||||
|   return { |   return { | ||||||
|     type: SEARCH_SUGGESTIONS_READY, |     type: SEARCH_SUGGESTIONS_READY, | ||||||
|     value, |     value, | ||||||
|     accounts |     accounts, | ||||||
|  |     hashtags, | ||||||
|  |     statuses | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -32,7 +34,7 @@ export function fetchSearchSuggestions(value) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     api(getState).get('/api/v1/accounts/search', { |     api(getState).get('/api/v1/search', { | ||||||
|       params: { |       params: { | ||||||
|         q: value, |         q: value, | ||||||
|         resolve: true, |         resolve: true, | ||||||
|  |  | ||||||
|  | @ -1,11 +1,16 @@ | ||||||
| import Avatar from '../../../components/avatar'; | import Avatar from '../../../components/avatar'; | ||||||
| import DisplayName from '../../../components/display_name'; | import DisplayName from '../../../components/display_name'; | ||||||
|  | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| 
 | 
 | ||||||
| const AutosuggestAccount = ({ account }) => ( | const AutosuggestAccount = ({ account }) => ( | ||||||
|   <div style={{ overflow: 'hidden' }}> |   <div style={{ overflow: 'hidden' }} className='autosuggest-account'> | ||||||
|     <div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div> |     <div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div> | ||||||
|     <DisplayName account={account} /> |     <DisplayName account={account} /> | ||||||
|   </div> |   </div> | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
|  | AutosuggestAccount.propTypes = { | ||||||
|  |   account: ImmutablePropTypes.map.isRequired | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export default AutosuggestAccount; | export default AutosuggestAccount; | ||||||
|  |  | ||||||
|  | @ -0,0 +1,15 @@ | ||||||
|  | import { FormattedMessage } from 'react-intl'; | ||||||
|  | import DisplayName from '../../../components/display_name'; | ||||||
|  | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
|  | 
 | ||||||
|  | const AutosuggestStatus = ({ status }) => ( | ||||||
|  |   <div style={{ overflow: 'hidden' }} className='autosuggest-status'> | ||||||
|  |     <FormattedMessage id='search.status_by' defaultMessage='Status by {name}' values={{ name: <strong>@{status.getIn(['account', 'acct'])}</strong> }} /> | ||||||
|  |   </div> | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | AutosuggestStatus.propTypes = { | ||||||
|  |   status: ImmutablePropTypes.map.isRequired | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default AutosuggestStatus; | ||||||
|  | @ -2,6 +2,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import Autosuggest from 'react-autosuggest'; | import Autosuggest from 'react-autosuggest'; | ||||||
| import AutosuggestAccountContainer from '../containers/autosuggest_account_container'; | import AutosuggestAccountContainer from '../containers/autosuggest_account_container'; | ||||||
|  | import AutosuggestStatusContainer from '../containers/autosuggest_status_container'; | ||||||
| import { debounce } from 'react-decoration'; | import { debounce } from 'react-decoration'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| 
 | 
 | ||||||
|  | @ -14,8 +15,10 @@ const getSuggestionValue = suggestion => suggestion.value; | ||||||
| const renderSuggestion = suggestion => { | const renderSuggestion = suggestion => { | ||||||
|   if (suggestion.type === 'account') { |   if (suggestion.type === 'account') { | ||||||
|     return <AutosuggestAccountContainer id={suggestion.id} />; |     return <AutosuggestAccountContainer id={suggestion.id} />; | ||||||
|  |   } else if (suggestion.type === 'hashtag') { | ||||||
|  |     return <span>#{suggestion.id}</span>; | ||||||
|   } else { |   } else { | ||||||
|     return <span>#{suggestion.id}</span> |     return <AutosuggestStatusContainer id={suggestion.id} />; | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -78,8 +81,10 @@ const Search = React.createClass({ | ||||||
|   onSuggestionSelected (_, { suggestion }) { |   onSuggestionSelected (_, { suggestion }) { | ||||||
|     if (suggestion.type === 'account') { |     if (suggestion.type === 'account') { | ||||||
|       this.context.router.push(`/accounts/${suggestion.id}`); |       this.context.router.push(`/accounts/${suggestion.id}`); | ||||||
|     } else { |     } else if(suggestion.type === 'hashtag') { | ||||||
|       this.context.router.push(`/timelines/tag/${suggestion.id}`); |       this.context.router.push(`/timelines/tag/${suggestion.id}`); | ||||||
|  |     } else { | ||||||
|  |       this.context.router.push(`/statuses/${suggestion.id}`); | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,15 @@ | ||||||
|  | import { connect } from 'react-redux'; | ||||||
|  | import AutosuggestStatus from '../components/autosuggest_status'; | ||||||
|  | import { makeGetStatus } from '../../../selectors'; | ||||||
|  | 
 | ||||||
|  | const makeMapStateToProps = () => { | ||||||
|  |   const getStatus = makeGetStatus(); | ||||||
|  | 
 | ||||||
|  |   const mapStateToProps = (state, { id }) => ({ | ||||||
|  |     status: getStatus(state, id) | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return mapStateToProps; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default connect(makeMapStateToProps)(AutosuggestStatus); | ||||||
|  | @ -90,7 +90,6 @@ export default function accounts(state = initialState, action) { | ||||||
|   case REBLOGS_FETCH_SUCCESS: |   case REBLOGS_FETCH_SUCCESS: | ||||||
|   case FAVOURITES_FETCH_SUCCESS: |   case FAVOURITES_FETCH_SUCCESS: | ||||||
|   case COMPOSE_SUGGESTIONS_READY: |   case COMPOSE_SUGGESTIONS_READY: | ||||||
|   case SEARCH_SUGGESTIONS_READY: |  | ||||||
|   case FOLLOW_REQUESTS_FETCH_SUCCESS: |   case FOLLOW_REQUESTS_FETCH_SUCCESS: | ||||||
|   case FOLLOW_REQUESTS_EXPAND_SUCCESS: |   case FOLLOW_REQUESTS_EXPAND_SUCCESS: | ||||||
|   case BLOCKS_FETCH_SUCCESS: |   case BLOCKS_FETCH_SUCCESS: | ||||||
|  | @ -98,6 +97,7 @@ export default function accounts(state = initialState, action) { | ||||||
|     return normalizeAccounts(state, action.accounts); |     return normalizeAccounts(state, action.accounts); | ||||||
|   case NOTIFICATIONS_REFRESH_SUCCESS: |   case NOTIFICATIONS_REFRESH_SUCCESS: | ||||||
|   case NOTIFICATIONS_EXPAND_SUCCESS: |   case NOTIFICATIONS_EXPAND_SUCCESS: | ||||||
|  |   case SEARCH_SUGGESTIONS_READY: | ||||||
|     return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); |     return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); | ||||||
|   case TIMELINE_REFRESH_SUCCESS: |   case TIMELINE_REFRESH_SUCCESS: | ||||||
|   case TIMELINE_EXPAND_SUCCESS: |   case TIMELINE_EXPAND_SUCCESS: | ||||||
|  |  | ||||||
|  | @ -11,28 +11,51 @@ const initialState = Immutable.Map({ | ||||||
|   suggestions: [] |   suggestions: [] | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const normalizeSuggestions = (state, value, accounts) => { | const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => { | ||||||
|   let newSuggestions = [ |   let newSuggestions = []; | ||||||
|     { | 
 | ||||||
|  |   if (accounts.length > 0) { | ||||||
|  |     newSuggestions.push({ | ||||||
|       title: 'account', |       title: 'account', | ||||||
|       items: accounts.map(item => ({ |       items: accounts.map(item => ({ | ||||||
|         type: 'account', |         type: 'account', | ||||||
|         id: item.id, |         id: item.id, | ||||||
|         value: item.acct |         value: item.acct | ||||||
|       })) |       })) | ||||||
|  |     }); | ||||||
|   } |   } | ||||||
|   ]; |  | ||||||
| 
 | 
 | ||||||
|   if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) { |   if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 || hashtags.length > 0) { | ||||||
|     newSuggestions.push({ |     let hashtagItems = hashtags.map(item => ({ | ||||||
|       title: 'hashtag', |       type: 'hashtag', | ||||||
|       items: [ |       id: item, | ||||||
|         { |       value: `#${item}` | ||||||
|  |     })); | ||||||
|  | 
 | ||||||
|  |     if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 && !value.startsWith('http://') && !value.startsWith('https://') && hashtags.indexOf(value) === -1) { | ||||||
|  |       hashtagItems.unshift({ | ||||||
|         type: 'hashtag', |         type: 'hashtag', | ||||||
|         id: value, |         id: value, | ||||||
|         value: `#${value}` |         value: `#${value}` | ||||||
|  |       }); | ||||||
|     } |     } | ||||||
|       ] | 
 | ||||||
|  |     if (hashtagItems.length > 0) { | ||||||
|  |       newSuggestions.push({ | ||||||
|  |         title: 'hashtag', | ||||||
|  |         items: hashtagItems | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   if (statuses.length > 0) { | ||||||
|  |     newSuggestions.push({ | ||||||
|  |       title: 'status', | ||||||
|  |       items: statuses.map(item => ({ | ||||||
|  |         type: 'status', | ||||||
|  |         id: item.id, | ||||||
|  |         value: item.id | ||||||
|  |       })) | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -47,7 +70,7 @@ export default function search(state = initialState, action) { | ||||||
|   case SEARCH_CHANGE: |   case SEARCH_CHANGE: | ||||||
|     return state.set('value', action.value); |     return state.set('value', action.value); | ||||||
|   case SEARCH_SUGGESTIONS_READY: |   case SEARCH_SUGGESTIONS_READY: | ||||||
|       return normalizeSuggestions(state, action.value, action.accounts); |     return normalizeSuggestions(state, action.value, action.accounts, action.hashtags, action.statuses); | ||||||
|   case SEARCH_RESET: |   case SEARCH_RESET: | ||||||
|     return state.withMutations(map => { |     return state.withMutations(map => { | ||||||
|       map.set('suggestions', []); |       map.set('suggestions', []); | ||||||
|  |  | ||||||
|  | @ -32,6 +32,7 @@ import { | ||||||
|   FAVOURITED_STATUSES_FETCH_SUCCESS, |   FAVOURITED_STATUSES_FETCH_SUCCESS, | ||||||
|   FAVOURITED_STATUSES_EXPAND_SUCCESS |   FAVOURITED_STATUSES_EXPAND_SUCCESS | ||||||
| } from '../actions/favourites'; | } from '../actions/favourites'; | ||||||
|  | import { SEARCH_SUGGESTIONS_READY } from '../actions/search'; | ||||||
| import Immutable from 'immutable'; | import Immutable from 'immutable'; | ||||||
| 
 | 
 | ||||||
| const normalizeStatus = (state, status) => { | const normalizeStatus = (state, status) => { | ||||||
|  | @ -108,6 +109,7 @@ export default function statuses(state = initialState, action) { | ||||||
|   case NOTIFICATIONS_EXPAND_SUCCESS: |   case NOTIFICATIONS_EXPAND_SUCCESS: | ||||||
|   case FAVOURITED_STATUSES_FETCH_SUCCESS: |   case FAVOURITED_STATUSES_FETCH_SUCCESS: | ||||||
|   case FAVOURITED_STATUSES_EXPAND_SUCCESS: |   case FAVOURITED_STATUSES_EXPAND_SUCCESS: | ||||||
|  |   case SEARCH_SUGGESTIONS_READY: | ||||||
|     return normalizeStatuses(state, action.statuses); |     return normalizeStatuses(state, action.statuses); | ||||||
|   case TIMELINE_DELETE: |   case TIMELINE_DELETE: | ||||||
|     return deleteStatus(state, action.id, action.references); |     return deleteStatus(state, action.id, action.references); | ||||||
|  |  | ||||||
|  | @ -1421,3 +1421,13 @@ button.active i.fa-retweet { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .autosuggest-status { | ||||||
|  |   overflow: hidden; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  | 
 | ||||||
|  |   strong { | ||||||
|  |     font-weight: 500; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -115,7 +115,7 @@ class Api::V1::AccountsController < ApiController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def search |   def search | ||||||
|     @accounts = SearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true', current_account) |     @accounts = AccountSearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true', current_account) | ||||||
| 
 | 
 | ||||||
|     set_account_counters_maps(@accounts) unless @accounts.nil? |     set_account_counters_maps(@accounts) unless @accounts.nil? | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class Api::V1::SearchController < ApiController | ||||||
|  |   respond_to :json | ||||||
|  | 
 | ||||||
|  |   def index | ||||||
|  |     @search = OpenStruct.new(SearchService.new.call(params[:q], 5, params[:resolve] == 'true', current_account)) | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -0,0 +1,39 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class StatusesController < ApplicationController | ||||||
|  |   layout 'public' | ||||||
|  | 
 | ||||||
|  |   before_action :set_account | ||||||
|  |   before_action :set_status | ||||||
|  |   before_action :set_link_headers | ||||||
|  |   before_action :check_account_suspension | ||||||
|  | 
 | ||||||
|  |   def show | ||||||
|  |     @ancestors   = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : [] | ||||||
|  |     @descendants = cache_collection(@status.descendants(current_account), Status) | ||||||
|  | 
 | ||||||
|  |     render 'stream_entries/show' | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def set_account | ||||||
|  |     @account = Account.find_local!(params[:account_username]) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def set_link_headers | ||||||
|  |     response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def set_status | ||||||
|  |     @status       = @account.statuses.find(params[:id]) | ||||||
|  |     @stream_entry = @status.stream_entry | ||||||
|  |     @type         = @stream_entry.activity_type.downcase | ||||||
|  | 
 | ||||||
|  |     raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def check_account_suspension | ||||||
|  |     gone if @account.suspended? | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -82,7 +82,9 @@ class TagManager | ||||||
| 
 | 
 | ||||||
|     case target.object_type |     case target.object_type | ||||||
|     when :person |     when :person | ||||||
|       account_url(target) |       short_account_url(target) | ||||||
|  |     when :note, :comment, :activity | ||||||
|  |       short_account_status_url(target.account, target) | ||||||
|     else |     else | ||||||
|       account_stream_entry_url(target.account, target.stream_entry) |       account_stream_entry_url(target.account, target.stream_entry) | ||||||
|     end |     end | ||||||
|  |  | ||||||
|  | @ -222,8 +222,9 @@ SQL | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def search_for(terms, limit = 10) |     def search_for(terms, limit = 10) | ||||||
|  |       terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' '))) | ||||||
|       textsearch = '(setweight(to_tsvector(\'simple\', accounts.display_name), \'A\') || setweight(to_tsvector(\'simple\', accounts.username), \'B\') || setweight(to_tsvector(\'simple\', coalesce(accounts.domain, \'\')), \'C\'))' |       textsearch = '(setweight(to_tsvector(\'simple\', accounts.display_name), \'A\') || setweight(to_tsvector(\'simple\', accounts.username), \'B\') || setweight(to_tsvector(\'simple\', coalesce(accounts.domain, \'\')), \'C\'))' | ||||||
|       query      = 'to_tsquery(\'simple\', \'\'\' \' || ? || \' \'\'\' || \':*\')' |       query      = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')' | ||||||
| 
 | 
 | ||||||
|       sql = <<SQL |       sql = <<SQL | ||||||
|         SELECT |         SELECT | ||||||
|  | @ -235,12 +236,13 @@ SQL | ||||||
|         LIMIT ? |         LIMIT ? | ||||||
| SQL | SQL | ||||||
| 
 | 
 | ||||||
|       Account.find_by_sql([sql, terms, terms, limit]) |       Account.find_by_sql([sql, limit]) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def advanced_search_for(terms, account, limit = 10) |     def advanced_search_for(terms, account, limit = 10) | ||||||
|  |       terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' '))) | ||||||
|       textsearch = '(setweight(to_tsvector(\'simple\', accounts.display_name), \'A\') || setweight(to_tsvector(\'simple\', accounts.username), \'B\') || setweight(to_tsvector(\'simple\', coalesce(accounts.domain, \'\')), \'C\'))' |       textsearch = '(setweight(to_tsvector(\'simple\', accounts.display_name), \'A\') || setweight(to_tsvector(\'simple\', accounts.username), \'B\') || setweight(to_tsvector(\'simple\', coalesce(accounts.domain, \'\')), \'C\'))' | ||||||
|       query      = 'to_tsquery(\'simple\', \'\'\' \' || ? || \' \'\'\' || \':*\')' |       query      = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')' | ||||||
| 
 | 
 | ||||||
|       sql = <<SQL |       sql = <<SQL | ||||||
|         SELECT |         SELECT | ||||||
|  | @ -254,7 +256,7 @@ SQL | ||||||
|         LIMIT ? |         LIMIT ? | ||||||
| SQL | SQL | ||||||
| 
 | 
 | ||||||
|       Account.find_by_sql([sql, terms, account.id, account.id, terms, limit]) |       Account.find_by_sql([sql, account.id, account.id, limit]) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def following_map(target_account_ids, account_id) |     def following_map(target_account_ids, account_id) | ||||||
|  |  | ||||||
|  | @ -10,4 +10,24 @@ class Tag < ApplicationRecord | ||||||
|   def to_param |   def to_param | ||||||
|     name |     name | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   class << self | ||||||
|  |     def search_for(terms, limit = 5) | ||||||
|  |       terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' '))) | ||||||
|  |       textsearch = 'to_tsvector(\'simple\', tags.name)' | ||||||
|  |       query      = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')' | ||||||
|  | 
 | ||||||
|  |       sql = <<SQL | ||||||
|  |         SELECT | ||||||
|  |           tags.*, | ||||||
|  |           ts_rank_cd(#{textsearch}, #{query}) AS rank | ||||||
|  |         FROM tags | ||||||
|  |         WHERE #{query} @@ #{textsearch} | ||||||
|  |         ORDER BY rank DESC | ||||||
|  |         LIMIT ? | ||||||
|  | SQL | ||||||
|  | 
 | ||||||
|  |       Tag.find_by_sql([sql, limit]) | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -0,0 +1,26 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class AccountSearchService < BaseService | ||||||
|  |   def call(query, limit, resolve = false, account = nil) | ||||||
|  |     return [] if query.blank? || query.start_with?('#') | ||||||
|  | 
 | ||||||
|  |     username, domain = query.gsub(/\A@/, '').split('@') | ||||||
|  |     domain = nil if TagManager.instance.local_domain?(domain) | ||||||
|  | 
 | ||||||
|  |     if domain.nil? | ||||||
|  |       exact_match = Account.find_local(username) | ||||||
|  |       results     = account.nil? ? Account.search_for(username, limit) : Account.advanced_search_for(username, account, limit) | ||||||
|  |     else | ||||||
|  |       exact_match = Account.find_remote(username, domain) | ||||||
|  |       results     = account.nil? ? Account.search_for("#{username} #{domain}", limit) : Account.advanced_search_for("#{username} #{domain}", account, limit) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match | ||||||
|  | 
 | ||||||
|  |     if resolve && !exact_match && !domain.nil? | ||||||
|  |       results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")] | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     results | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -47,6 +47,6 @@ class FetchAtomService < BaseService | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def http_client |   def http_client | ||||||
|     HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50).follow |     HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -1,8 +1,13 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class FetchRemoteAccountService < BaseService | class FetchRemoteAccountService < BaseService | ||||||
|   def call(url) |   def call(url, prefetched_body = nil) | ||||||
|  |     if prefetched_body.nil? | ||||||
|       atom_url, body = FetchAtomService.new.call(url) |       atom_url, body = FetchAtomService.new.call(url) | ||||||
|  |     else | ||||||
|  |       atom_url = url | ||||||
|  |       body     = prefetched_body | ||||||
|  |     end | ||||||
| 
 | 
 | ||||||
|     return nil if atom_url.nil? |     return nil if atom_url.nil? | ||||||
|     process_atom(atom_url, body) |     process_atom(atom_url, body) | ||||||
|  |  | ||||||
|  | @ -0,0 +1,18 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class FetchRemoteResourceService < BaseService | ||||||
|  |   def call(url) | ||||||
|  |     atom_url, body = FetchAtomService.new.call(url) | ||||||
|  | 
 | ||||||
|  |     return nil if atom_url.nil? | ||||||
|  | 
 | ||||||
|  |     xml = Nokogiri::XML(body) | ||||||
|  |     xml.encoding = 'utf-8' | ||||||
|  | 
 | ||||||
|  |     if xml.root.name == 'feed' | ||||||
|  |       FetchRemoteAccountService.new.call(atom_url, body) | ||||||
|  |     elsif xml.root.name == 'entry' | ||||||
|  |       FetchRemoteStatusService.new.call(atom_url, body) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -1,8 +1,13 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class FetchRemoteStatusService < BaseService | class FetchRemoteStatusService < BaseService | ||||||
|   def call(url) |   def call(url, prefetched_body = nil) | ||||||
|  |     if prefetched_body.nil? | ||||||
|       atom_url, body = FetchAtomService.new.call(url) |       atom_url, body = FetchAtomService.new.call(url) | ||||||
|  |     else | ||||||
|  |       atom_url = url | ||||||
|  |       body     = prefetched_body | ||||||
|  |     end | ||||||
| 
 | 
 | ||||||
|     return nil if atom_url.nil? |     return nil if atom_url.nil? | ||||||
|     process_atom(atom_url, body) |     process_atom(atom_url, body) | ||||||
|  |  | ||||||
|  | @ -64,7 +64,7 @@ class ProcessInteractionService < BaseService | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def mentions_account?(xml, account) |   def mentions_account?(xml, account) | ||||||
|     xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each { |mention_link| return true if mention_link.attribute('href').value == TagManager.instance.url_for(account) } |     xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each { |mention_link| return true if [TagManager.instance.uri_for(account), TagManager.instance.url_for(account)].include?(mention_link.attribute('href').value) } | ||||||
|     false |     false | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,23 +2,18 @@ | ||||||
| 
 | 
 | ||||||
| class SearchService < BaseService | class SearchService < BaseService | ||||||
|   def call(query, limit, resolve = false, account = nil) |   def call(query, limit, resolve = false, account = nil) | ||||||
|     return if query.blank? || query.start_with?('#') |     return if query.blank? | ||||||
| 
 | 
 | ||||||
|     username, domain = query.gsub(/\A@/, '').split('@') |     results = { accounts: [], hashtags: [], statuses: [] } | ||||||
|     domain = nil if TagManager.instance.local_domain?(domain) |  | ||||||
| 
 | 
 | ||||||
|     if domain.nil? |     if query =~ /\Ahttps?:\/\// | ||||||
|       exact_match = Account.find_local(username) |       resource = FetchRemoteResourceService.new.call(query) | ||||||
|       results     = account.nil? ? Account.search_for(username, limit) : Account.advanced_search_for(username, account, limit) | 
 | ||||||
|  |       results[:accounts] << resource if resource.is_a?(Account) | ||||||
|  |       results[:statuses] << resource if resource.is_a?(Status) | ||||||
|     else |     else | ||||||
|       exact_match = Account.find_remote(username, domain) |       results[:accounts] = AccountSearchService.new.call(query, limit, resolve, account) | ||||||
|       results     = account.nil? ? Account.search_for("#{username} #{domain}", limit) : Account.advanced_search_for("#{username} #{domain}", account, limit) |       results[:hashtags] = Tag.search_for(query.gsub(/\A#/, ''), limit) unless query.start_with?('@') | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match |  | ||||||
| 
 |  | ||||||
|     if resolve && !exact_match && !domain.nil? |  | ||||||
|       results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")] |  | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     results |     results | ||||||
|  |  | ||||||
|  | @ -20,8 +20,8 @@ | ||||||
|       .account__header__content.p-note.emojify= Formatter.instance.simplified_format(@account) |       .account__header__content.p-note.emojify= Formatter.instance.simplified_format(@account) | ||||||
| 
 | 
 | ||||||
|     .details-counters |     .details-counters | ||||||
|       .counter{ class: active_nav_class(account_url(@account)) } |       .counter{ class: active_nav_class(short_account_url(@account)) } | ||||||
|         = link_to account_url(@account), class: 'u-url u-uid' do |         = link_to short_account_url(@account), class: 'u-url u-uid' do | ||||||
|           %span.counter-label= t('accounts.posts') |           %span.counter-label= t('accounts.posts') | ||||||
|           %span.counter-number= number_with_delimiter @account.statuses.count |           %span.counter-number= number_with_delimiter @account.statuses.count | ||||||
|       .counter{ class: active_nav_class(following_account_url(@account)) } |       .counter{ class: active_nav_class(following_account_url(@account)) } | ||||||
|  |  | ||||||
|  | @ -0,0 +1,13 @@ | ||||||
|  | object @search | ||||||
|  | 
 | ||||||
|  | child :accounts, object_root: false do | ||||||
|  |   extends 'api/v1/accounts/show' | ||||||
|  | end | ||||||
|  | 
 | ||||||
|  | node(:hashtags) do |search| | ||||||
|  |   search.hashtags.map(&:name) | ||||||
|  | end | ||||||
|  | 
 | ||||||
|  | child :statuses, object_root: false do | ||||||
|  |   extends 'api/v1/statuses/show' | ||||||
|  | end | ||||||
|  | @ -4,7 +4,7 @@ | ||||||
| - centered        ||= include_threads && !is_predecessor && !is_successor | - centered        ||= include_threads && !is_predecessor && !is_successor | ||||||
| 
 | 
 | ||||||
| - if status.reply? && include_threads | - if status.reply? && include_threads | ||||||
|   = render partial: 'status', collection: @ancestors, as: :status, locals: { is_predecessor: true } |   = render partial: 'stream_entries/status', collection: @ancestors, as: :status, locals: { is_predecessor: true } | ||||||
| 
 | 
 | ||||||
| .entry{ class: entry_classes(status, is_predecessor, is_successor, include_threads) } | .entry{ class: entry_classes(status, is_predecessor, is_successor, include_threads) } | ||||||
|   - if status.reblog? |   - if status.reblog? | ||||||
|  | @ -19,4 +19,4 @@ | ||||||
|   = render partial: centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status', locals: { status: proper_status(status) } |   = render partial: centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status', locals: { status: proper_status(status) } | ||||||
| 
 | 
 | ||||||
| - if include_threads | - if include_threads | ||||||
|   = render partial: 'status', collection: @descendants, as: :status, locals: { is_successor: true } |   = render partial: 'stream_entries/status', collection: @descendants, as: :status, locals: { is_successor: true } | ||||||
|  |  | ||||||
|  | @ -24,4 +24,4 @@ | ||||||
|   = render partial: 'shared/landing_strip', locals: { account: @stream_entry.account } |   = render partial: 'shared/landing_strip', locals: { account: @stream_entry.account } | ||||||
| 
 | 
 | ||||||
| .activity-stream.activity-stream-headless | .activity-stream.activity-stream-headless | ||||||
|   = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, include_threads: true } |   = render partial: "stream_entries/#{@type}", locals: { @type.to_sym => @stream_entry.activity, include_threads: true } | ||||||
|  |  | ||||||
|  | @ -24,6 +24,8 @@ Rails.application.routes.draw do | ||||||
|     confirmations:      'auth/confirmations', |     confirmations:      'auth/confirmations', | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   get '/users/:username', to: redirect('/@%{username}'), constraints: { format: :html } | ||||||
|  | 
 | ||||||
|   resources :accounts, path: 'users', only: [:show], param: :username do |   resources :accounts, path: 'users', only: [:show], param: :username do | ||||||
|     resources :stream_entries, path: 'updates', only: [:show] do |     resources :stream_entries, path: 'updates', only: [:show] do | ||||||
|       member do |       member do | ||||||
|  | @ -43,6 +45,9 @@ Rails.application.routes.draw do | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   get '/@:username', to: 'accounts#show', as: :short_account | ||||||
|  |   get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status | ||||||
|  | 
 | ||||||
|   namespace :settings do |   namespace :settings do | ||||||
|     resource :profile, only: [:show, :update] |     resource :profile, only: [:show, :update] | ||||||
|     resource :preferences, only: [:show, :update] |     resource :preferences, only: [:show, :update] | ||||||
|  | @ -129,6 +134,8 @@ Rails.application.routes.draw do | ||||||
|       get '/timelines/public',   to: 'timelines#public', as: :public_timeline |       get '/timelines/public',   to: 'timelines#public', as: :public_timeline | ||||||
|       get '/timelines/tag/:id',  to: 'timelines#tag', as: :hashtag_timeline |       get '/timelines/tag/:id',  to: 'timelines#tag', as: :hashtag_timeline | ||||||
| 
 | 
 | ||||||
|  |       get '/search', to: 'search#index', as: :search | ||||||
|  | 
 | ||||||
|       resources :follows,    only: [:create] |       resources :follows,    only: [:create] | ||||||
|       resources :media,      only: [:create] |       resources :media,      only: [:create] | ||||||
|       resources :apps,       only: [:create] |       resources :apps,       only: [:create] | ||||||
|  | @ -187,8 +194,5 @@ Rails.application.routes.draw do | ||||||
| 
 | 
 | ||||||
|   root 'home#index' |   root 'home#index' | ||||||
| 
 | 
 | ||||||
|   get '/:username', to: redirect('/users/%{username}') |  | ||||||
|   get '/:username/:id', to: redirect('/users/%{username}/updates/%{id}') |  | ||||||
| 
 |  | ||||||
|   match '*unmatched_route', via: :all, to: 'application#raise_not_found' |   match '*unmatched_route', via: :all, to: 'application#raise_not_found' | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | class AddSearchIndexToTags < ActiveRecord::Migration[5.0] | ||||||
|  |   def up | ||||||
|  |     execute 'CREATE INDEX hashtag_search_index ON tags USING gin(to_tsvector(\'simple\', tags.name));' | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     remove_index :tags, name: :hashtag_search_index | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -10,7 +10,7 @@ | ||||||
| # | # | ||||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||||
| 
 | 
 | ||||||
| ActiveRecord::Schema.define(version: 20170322143850) do | ActiveRecord::Schema.define(version: 20170322162804) do | ||||||
| 
 | 
 | ||||||
|   # These are extensions that must be enabled in order to support this database |   # These are extensions that must be enabled in order to support this database | ||||||
|   enable_extension "plpgsql" |   enable_extension "plpgsql" | ||||||
|  | @ -259,6 +259,7 @@ ActiveRecord::Schema.define(version: 20170322143850) do | ||||||
|     t.string   "name",       default: "", null: false |     t.string   "name",       default: "", null: false | ||||||
|     t.datetime "created_at",              null: false |     t.datetime "created_at",              null: false | ||||||
|     t.datetime "updated_at",              null: false |     t.datetime "updated_at",              null: false | ||||||
|  |     t.index "to_tsvector('simple'::regconfig, (name)::text)", name: "hashtag_search_index", using: :gin | ||||||
|     t.index ["name"], name: "index_tags_on_name", unique: true, using: :btree |     t.index ["name"], name: "index_tags_on_name", unique: true, using: :btree | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -6,7 +6,6 @@ RSpec.describe Api::SalmonController, type: :controller do | ||||||
|   let(:account) { Fabricate(:user, account: Fabricate(:account, username: 'catsrgr8')).account } |   let(:account) { Fabricate(:user, account: Fabricate(:account, username: 'catsrgr8')).account } | ||||||
| 
 | 
 | ||||||
|   before do |   before do | ||||||
|     stub_request(:post, "https://pubsubhubbub.superfeedr.com/").to_return(:status => 200, :body => "", :headers => {}) |  | ||||||
|     stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt')) |     stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt')) | ||||||
|     stub_request(:get, "https://quitter.no/.well-known/webfinger?resource=acct:gargron@quitter.no").to_return(request_fixture('webfinger.txt')) |     stub_request(:get, "https://quitter.no/.well-known/webfinger?resource=acct:gargron@quitter.no").to_return(request_fixture('webfinger.txt')) | ||||||
|     stub_request(:get, "https://quitter.no/api/statuses/user_timeline/7477.atom").to_return(request_fixture('feed.txt')) |     stub_request(:get, "https://quitter.no/api/statuses/user_timeline/7477.atom").to_return(request_fixture('feed.txt')) | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue