diff --git a/app/assets/javascripts/components/actions/search.jsx b/app/assets/javascripts/components/actions/search.jsx
index ceb0e4a0c1c..e4af716eef8 100644
--- a/app/assets/javascripts/components/actions/search.jsx
+++ b/app/assets/javascripts/components/actions/search.jsx
@@ -18,11 +18,13 @@ export function clearSearchSuggestions() {
};
};
-export function readySearchSuggestions(value, accounts) {
+export function readySearchSuggestions(value, { accounts, hashtags, statuses }) {
return {
type: SEARCH_SUGGESTIONS_READY,
value,
- accounts
+ accounts,
+ hashtags,
+ statuses
};
};
@@ -32,7 +34,7 @@ export function fetchSearchSuggestions(value) {
return;
}
- api(getState).get('/api/v1/accounts/search', {
+ api(getState).get('/api/v1/search', {
params: {
q: value,
resolve: true,
diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
index 9ea7f190fb4..5591b45cfbb 100644
--- a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
+++ b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
@@ -1,11 +1,16 @@
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
const AutosuggestAccount = ({ account }) => (
-
+
);
+AutosuggestAccount.propTypes = {
+ account: ImmutablePropTypes.map.isRequired
+};
+
export default AutosuggestAccount;
diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx
new file mode 100644
index 00000000000..086488649dc
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx
@@ -0,0 +1,15 @@
+import { FormattedMessage } from 'react-intl';
+import DisplayName from '../../../components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const AutosuggestStatus = ({ status }) => (
+
+ @{status.getIn(['account', 'acct'])} }} />
+
+);
+
+AutosuggestStatus.propTypes = {
+ status: ImmutablePropTypes.map.isRequired
+};
+
+export default AutosuggestStatus;
diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx
index c1f23939dbc..a0e8f82fbc7 100644
--- a/app/assets/javascripts/components/features/compose/components/search.jsx
+++ b/app/assets/javascripts/components/features/compose/components/search.jsx
@@ -2,6 +2,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Autosuggest from 'react-autosuggest';
import AutosuggestAccountContainer from '../containers/autosuggest_account_container';
+import AutosuggestStatusContainer from '../containers/autosuggest_status_container';
import { debounce } from 'react-decoration';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@@ -14,8 +15,10 @@ const getSuggestionValue = suggestion => suggestion.value;
const renderSuggestion = suggestion => {
if (suggestion.type === 'account') {
return
;
+ } else if (suggestion.type === 'hashtag') {
+ return
#{suggestion.id};
} else {
- return
#{suggestion.id}
+ return
;
}
};
@@ -78,8 +81,10 @@ const Search = React.createClass({
onSuggestionSelected (_, { suggestion }) {
if (suggestion.type === 'account') {
this.context.router.push(`/accounts/${suggestion.id}`);
- } else {
+ } else if(suggestion.type === 'hashtag') {
this.context.router.push(`/timelines/tag/${suggestion.id}`);
+ } else {
+ this.context.router.push(`/statuses/${suggestion.id}`);
}
},
diff --git a/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx b/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx
new file mode 100644
index 00000000000..ef46eb09caa
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx
@@ -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);
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index f3938cee1be..6ce41670d60 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -90,7 +90,6 @@ export default function accounts(state = initialState, action) {
case REBLOGS_FETCH_SUCCESS:
case FAVOURITES_FETCH_SUCCESS:
case COMPOSE_SUGGESTIONS_READY:
- case SEARCH_SUGGESTIONS_READY:
case FOLLOW_REQUESTS_FETCH_SUCCESS:
case FOLLOW_REQUESTS_EXPAND_SUCCESS:
case BLOCKS_FETCH_SUCCESS:
@@ -98,6 +97,7 @@ export default function accounts(state = initialState, action) {
return normalizeAccounts(state, action.accounts);
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
+ case SEARCH_SUGGESTIONS_READY:
return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx
index d835ef26822..e95f9ed79f6 100644
--- a/app/assets/javascripts/components/reducers/search.jsx
+++ b/app/assets/javascripts/components/reducers/search.jsx
@@ -11,28 +11,51 @@ const initialState = Immutable.Map({
suggestions: []
});
-const normalizeSuggestions = (state, value, accounts) => {
- let newSuggestions = [
- {
+const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => {
+ let newSuggestions = [];
+
+ if (accounts.length > 0) {
+ newSuggestions.push({
title: 'account',
items: accounts.map(item => ({
type: 'account',
id: item.id,
value: item.acct
}))
- }
- ];
+ });
+ }
- if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) {
+ if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 || hashtags.length > 0) {
+ let hashtagItems = hashtags.map(item => ({
+ type: 'hashtag',
+ 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',
+ id: value,
+ value: `#${value}`
+ });
+ }
+
+ if (hashtagItems.length > 0) {
+ newSuggestions.push({
+ title: 'hashtag',
+ items: hashtagItems
+ });
+ }
+ }
+
+ if (statuses.length > 0) {
newSuggestions.push({
- title: 'hashtag',
- items: [
- {
- type: 'hashtag',
- id: value,
- value: `#${value}`
- }
- ]
+ title: 'status',
+ items: statuses.map(item => ({
+ type: 'status',
+ id: item.id,
+ value: item.id
+ }))
});
}
@@ -44,17 +67,17 @@ const normalizeSuggestions = (state, value, accounts) => {
export default function search(state = initialState, action) {
switch(action.type) {
- case SEARCH_CHANGE:
- return state.set('value', action.value);
- case SEARCH_SUGGESTIONS_READY:
- return normalizeSuggestions(state, action.value, action.accounts);
- case SEARCH_RESET:
- return state.withMutations(map => {
- map.set('suggestions', []);
- map.set('value', '');
- map.set('loaded_value', '');
- });
- default:
- return state;
+ case SEARCH_CHANGE:
+ return state.set('value', action.value);
+ case SEARCH_SUGGESTIONS_READY:
+ return normalizeSuggestions(state, action.value, action.accounts, action.hashtags, action.statuses);
+ case SEARCH_RESET:
+ return state.withMutations(map => {
+ map.set('suggestions', []);
+ map.set('value', '');
+ map.set('loaded_value', '');
+ });
+ default:
+ return state;
}
};
diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx
index ce791eab602..1669b8c65e8 100644
--- a/app/assets/javascripts/components/reducers/statuses.jsx
+++ b/app/assets/javascripts/components/reducers/statuses.jsx
@@ -32,6 +32,7 @@ import {
FAVOURITED_STATUSES_FETCH_SUCCESS,
FAVOURITED_STATUSES_EXPAND_SUCCESS
} from '../actions/favourites';
+import { SEARCH_SUGGESTIONS_READY } from '../actions/search';
import Immutable from 'immutable';
const normalizeStatus = (state, status) => {
@@ -108,6 +109,7 @@ export default function statuses(state = initialState, action) {
case NOTIFICATIONS_EXPAND_SUCCESS:
case FAVOURITED_STATUSES_FETCH_SUCCESS:
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ case SEARCH_SUGGESTIONS_READY:
return normalizeStatuses(state, action.statuses);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 4b1e86aca47..057c61f911f 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -1421,3 +1421,13 @@ button.active i.fa-retweet {
}
}
}
+
+.autosuggest-status {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+
+ strong {
+ font-weight: 500;
+ }
+}
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 51c94895598..f07450eb077 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -115,7 +115,7 @@ class Api::V1::AccountsController < ApiController
end
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?
diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb
new file mode 100644
index 00000000000..6b12924581d
--- /dev/null
+++ b/app/controllers/api/v1/search_controller.rb
@@ -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
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
new file mode 100644
index 00000000000..696bb4f521c
--- /dev/null
+++ b/app/controllers/statuses_controller.rb
@@ -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
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index 9fef70fda6f..34c3edc4b7c 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -82,7 +82,9 @@ class TagManager
case target.object_type
when :person
- account_url(target)
+ short_account_url(target)
+ when :note, :comment, :activity
+ short_account_status_url(target.account, target)
else
account_stream_entry_url(target.account, target.stream_entry)
end
diff --git a/app/models/account.rb b/app/models/account.rb
index c0cd2ff64c5..6968607a2af 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -222,8 +222,9 @@ SQL
end
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\'))'
- query = 'to_tsquery(\'simple\', \'\'\' \' || ? || \' \'\'\' || \':*\')'
+ query = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')'
sql = <
@stream_entry.activity, include_threads: true }
+ = render partial: "stream_entries/#{@type}", locals: { @type.to_sym => @stream_entry.activity, include_threads: true }
diff --git a/config/routes.rb b/config/routes.rb
index ea766e1b32f..cf83649681b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -24,6 +24,8 @@ Rails.application.routes.draw do
confirmations: 'auth/confirmations',
}
+ get '/users/:username', to: redirect('/@%{username}'), constraints: { format: :html }
+
resources :accounts, path: 'users', only: [:show], param: :username do
resources :stream_entries, path: 'updates', only: [:show] do
member do
@@ -43,6 +45,9 @@ Rails.application.routes.draw do
end
end
+ get '/@:username', to: 'accounts#show', as: :short_account
+ get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status
+
namespace :settings do
resource :profile, 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/tag/:id', to: 'timelines#tag', as: :hashtag_timeline
+ get '/search', to: 'search#index', as: :search
+
resources :follows, only: [:create]
resources :media, only: [:create]
resources :apps, only: [:create]
@@ -187,8 +194,5 @@ Rails.application.routes.draw do
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'
end
diff --git a/db/migrate/20170322162804_add_search_index_to_tags.rb b/db/migrate/20170322162804_add_search_index_to_tags.rb
new file mode 100644
index 00000000000..415dff9a076
--- /dev/null
+++ b/db/migrate/20170322162804_add_search_index_to_tags.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 44b52c220b4..2457b523dfb 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# 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
enable_extension "plpgsql"
@@ -259,6 +259,7 @@ ActiveRecord::Schema.define(version: 20170322143850) do
t.string "name", default: "", null: false
t.datetime "created_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
end
diff --git a/spec/controllers/api/salmon_controller_spec.rb b/spec/controllers/api/salmon_controller_spec.rb
index 3d3a973d228..6897caeebbe 100644
--- a/spec/controllers/api/salmon_controller_spec.rb
+++ b/spec/controllers/api/salmon_controller_spec.rb
@@ -6,7 +6,6 @@ RSpec.describe Api::SalmonController, type: :controller do
let(:account) { Fabricate(:user, account: Fabricate(:account, username: 'catsrgr8')).account }
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/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'))