diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 01eee17125..1dd7708489 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -1,4 +1,5 @@
import api from '../api'
+import Immutable from 'immutable';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@@ -54,20 +55,25 @@ export function refreshTimelineRequest(timeline) {
};
};
-export function refreshTimeline(timeline, replace = false) {
+export function refreshTimeline(timeline, replace = false, id = null) {
return function (dispatch, getState) {
dispatch(refreshTimelineRequest(timeline));
- const ids = getState().getIn(['timelines', timeline]);
+ const ids = getState().getIn(['timelines', timeline], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
+ let path = timeline;
if (newestId !== null && !replace) {
params = `?since_id=${newestId}`;
}
- api(getState).get(`/api/v1/statuses/${timeline}${params}`).then(function (response) {
+ if (id) {
+ path = `${path}/${id}`
+ }
+
+ api(getState).get(`/api/v1/statuses/${path}${params}`).then(function (response) {
dispatch(refreshTimelineSuccess(timeline, response.data, replace));
}).catch(function (error) {
dispatch(refreshTimelineFail(timeline, error));
@@ -83,13 +89,19 @@ export function refreshTimelineFail(timeline, error) {
};
};
-export function expandTimeline(timeline) {
+export function expandTimeline(timeline, id = null) {
return (dispatch, getState) => {
- const lastId = getState().getIn(['timelines', timeline]).last();
+ const lastId = getState().getIn(['timelines', timeline], Immutable.List()).last();
dispatch(expandTimelineRequest(timeline));
- api(getState).get(`/api/v1/statuses/${timeline}?max_id=${lastId}`).then(response => {
+ let path = timeline;
+
+ if (id) {
+ path = `${path}/${id}`
+ }
+
+ api(getState).get(`/api/v1/statuses/${path}?max_id=${lastId}`).then(response => {
dispatch(expandTimelineSuccess(timeline, response.data));
}).catch(error => {
dispatch(expandTimelineFail(timeline, error));
diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx
index 3574652480..2006e965a3 100644
--- a/app/assets/javascripts/components/components/status_content.jsx
+++ b/app/assets/javascripts/components/components/status_content.jsx
@@ -23,11 +23,14 @@ const StatusContent = React.createClass({
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+ } else if (link.text[0] === '#' || (link.previousSibling && link.previousSibling.text === '#')) {
+ link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
- link.addEventListener('click', this.onNormalClick, false);
}
+
+ link.addEventListener('click', this.onNormalClick, false);
}
},
@@ -36,8 +39,15 @@ const StatusContent = React.createClass({
e.preventDefault();
this.context.router.push(`/accounts/${mention.get('id')}`);
}
+ },
- e.stopPropagation();
+ onHashtagClick (hashtag, e) {
+ hashtag = hashtag.replace(/^#/, '').toLowerCase();
+
+ if (e.button === 0) {
+ e.preventDefault();
+ this.context.router.push(`/statuses/tag/${hashtag}`);
+ }
},
onNormalClick (e) {
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index bf92e248d2..f29893ec03 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -30,6 +30,7 @@ import Followers from '../features/followers';
import Following from '../features/following';
import Reblogs from '../features/reblogs';
import Favourites from '../features/favourites';
+import HashtagTimeline from '../features/hashtag_timeline';
const store = configureStore();
@@ -85,6 +86,7 @@ const Mastodon = React.createClass({
+
diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx
index 6cadcff4d7..818979f8f5 100644
--- a/app/assets/javascripts/components/features/account/index.jsx
+++ b/app/assets/javascripts/components/features/account/index.jsx
@@ -47,7 +47,7 @@ const Account = React.createClass({
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
},
- componentWillReceiveProps(nextProps) {
+ componentWillReceiveProps (nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
}
diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
new file mode 100644
index 0000000000..de6a9618e1
--- /dev/null
+++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
@@ -0,0 +1,72 @@
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../ui/components/column';
+import {
+ refreshTimeline,
+ updateTimeline
+} from '../../actions/timelines';
+
+const HashtagTimeline = React.createClass({
+
+ propTypes: {
+ params: React.PropTypes.object.isRequired,
+ dispatch: React.PropTypes.func.isRequired
+ },
+
+ mixins: [PureRenderMixin],
+
+ _subscribe (dispatch, id) {
+ if (typeof App !== 'undefined') {
+ this.subscription = App.cable.subscriptions.create({
+ channel: 'HashtagChannel',
+ tag: id
+ }, {
+
+ received (data) {
+ dispatch(updateTimeline('tag', JSON.parse(data.message)));
+ }
+
+ });
+ }
+ },
+
+ _unsubscribe () {
+ if (typeof this.subscription !== 'undefined') {
+ this.subscription.unsubscribe();
+ }
+ },
+
+ componentWillMount () {
+ const { dispatch } = this.props;
+ const { id } = this.props.params;
+
+ dispatch(refreshTimeline('tag', true, id));
+ this._subscribe(dispatch, id);
+ },
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.id !== this.props.params.id) {
+ this.props.dispatch(refreshTimeline('tag', true, nextProps.params.id));
+ this._unsubscribe();
+ this._subscribe(this.props.dispatch, nextProps.params.id);
+ }
+ },
+
+ componentWillUnmount () {
+ this._unsubscribe();
+ },
+
+ render () {
+ const { id } = this.props.params;
+
+ return (
+
+
+
+ );
+ },
+
+});
+
+export default connect()(HashtagTimeline);
diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
index 213435a06c..8004e3f044 100644
--- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
@@ -1,15 +1,16 @@
-import { connect } from 'react-redux';
-import StatusList from '../../../components/status_list';
-import { expandTimeline } from '../../../actions/timelines';
+import { connect } from 'react-redux';
+import StatusList from '../../../components/status_list';
+import { expandTimeline } from '../../../actions/timelines';
+import Immutable from 'immutable';
const mapStateToProps = (state, props) => ({
- statusIds: state.getIn(['timelines', props.type])
+ statusIds: state.getIn(['timelines', props.type], Immutable.List())
});
const mapDispatchToProps = function (dispatch, props) {
return {
onScrollToBottom () {
- dispatch(expandTimeline(props.type));
+ dispatch(expandTimeline(props.type, props.id));
}
};
};
diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx
index c12d1b70dd..9e79a41008 100644
--- a/app/assets/javascripts/components/reducers/timelines.jsx
+++ b/app/assets/javascripts/components/reducers/timelines.jsx
@@ -25,6 +25,7 @@ const initialState = Immutable.Map({
home: Immutable.List(),
mentions: Immutable.List(),
public: Immutable.List(),
+ tag: Immutable.List(),
accounts_timelines: Immutable.Map(),
ancestors: Immutable.Map(),
descendants: Immutable.Map()
@@ -55,7 +56,7 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => {
ids = ids.set(i, status.get('id'));
});
- return state.update(timeline, list => (replace ? ids : list.unshift(...ids)));
+ return state.update(timeline, Immutable.List(), list => (replace ? ids : list.unshift(...ids)));
};
const appendNormalizedTimeline = (state, timeline, statuses) => {
@@ -66,7 +67,7 @@ const appendNormalizedTimeline = (state, timeline, statuses) => {
moreIds = moreIds.set(i, status.get('id'));
});
- return state.update(timeline, list => list.push(...moreIds));
+ return state.update(timeline, Immutable.List(), list => list.push(...moreIds));
};
const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => {
@@ -94,7 +95,7 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
const updateTimeline = (state, timeline, status, references) => {
state = normalizeStatus(state, status);
- state = state.update(timeline, list => {
+ state = state.update(timeline, Immutable.List(), list => {
if (list.includes(status.get('id'))) {
return list;
}
@@ -113,7 +114,7 @@ const updateTimeline = (state, timeline, status, references) => {
const deleteStatus = (state, id, accountId, references) => {
// Remove references from timelines
- ['home', 'mentions', 'public'].forEach(function (timeline) {
+ ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
state = state.update(timeline, list => list.filterNot(item => item === id));
});
diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb
index d672697283..d27b058fb0 100644
--- a/app/channels/application_cable/channel.rb
+++ b/app/channels/application_cable/channel.rb
@@ -1,4 +1,17 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
+ protected
+
+ def hydrate_status(encoded_message)
+ message = ActiveSupport::JSON.decode(encoded_message)
+ status = Status.find_by(id: message['id'])
+ message['message'] = FeedManager.instance.inline_render(current_user.account, status)
+
+ [status, message]
+ end
+
+ def filter?(status)
+ status.nil? || current_user.account.blocking?(status.account) || (status.reblog? && current_user.account.blocking?(status.reblog.account))
+ end
end
end
diff --git a/app/channels/hashtag_channel.rb b/app/channels/hashtag_channel.rb
new file mode 100644
index 0000000000..5be8d94cd3
--- /dev/null
+++ b/app/channels/hashtag_channel.rb
@@ -0,0 +1,11 @@
+class HashtagChannel < ApplicationCable::Channel
+ def subscribed
+ tag = params[:tag].downcase
+
+ stream_from "timeline:hashtag:#{tag}", lambda { |encoded_message|
+ status, message = hydrate_status(encoded_message)
+ next if filter?(status)
+ transmit message
+ }
+ end
+end
diff --git a/app/channels/public_channel.rb b/app/channels/public_channel.rb
index 708eff0556..41e21611d7 100644
--- a/app/channels/public_channel.rb
+++ b/app/channels/public_channel.rb
@@ -1,19 +1,9 @@
-# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
class PublicChannel < ApplicationCable::Channel
def subscribed
stream_from 'timeline:public', lambda { |encoded_message|
- message = ActiveSupport::JSON.decode(encoded_message)
-
- status = Status.find_by(id: message['id'])
- next if status.nil? || current_user.account.blocking?(status.account) || (status.reblog? && current_user.account.blocking?(status.reblog.account))
-
- message['message'] = FeedManager.instance.inline_render(current_user.account, status)
-
+ status, message = hydrate_status(encoded_message)
+ next if filter?(status)
transmit message
}
end
-
- def unsubscribed
- # Any cleanup needed when channel is unsubscribed
- end
end
diff --git a/app/channels/timeline_channel.rb b/app/channels/timeline_channel.rb
index 9e5a811882..f2a9636fd7 100644
--- a/app/channels/timeline_channel.rb
+++ b/app/channels/timeline_channel.rb
@@ -2,8 +2,4 @@ class TimelineChannel < ApplicationCable::Channel
def subscribed
stream_from "timeline:#{current_user.account_id}"
end
-
- def unsubscribed
- # Any cleanup needed when channel is unsubscribed
- end
end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index b05a27ef49..0a823e3e66 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -74,6 +74,19 @@ class Api::V1::StatusesController < ApiController
render action: :index
end
+ def tag
+ @tag = Tag.find_by(name: params[:id].downcase)
+
+ if @tag.nil?
+ @statuses = []
+ else
+ @statuses = Status.as_tag_timeline(@tag, current_user.account).paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
+ set_maps(@statuses)
+ end
+
+ render action: :index
+ end
+
private
def set_status
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
new file mode 100644
index 0000000000..c1aaf7e479
--- /dev/null
+++ b/app/controllers/tags_controller.rb
@@ -0,0 +1,4 @@
+class TagsController < ApplicationController
+ def show
+ end
+end
diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb
index c7131074de..2eed2da65d 100644
--- a/app/helpers/atom_builder_helper.rb
+++ b/app/helpers/atom_builder_helper.rb
@@ -47,6 +47,10 @@ module AtomBuilderHelper
xml.author(&block)
end
+ def category(xml, tag)
+ xml.category(term: tag.name)
+ end
+
def target(xml, &block)
xml['activity'].object(&block)
end
@@ -186,6 +190,10 @@ module AtomBuilderHelper
stream_entry.target.media_attachments.each do |media|
link_enclosure xml, media
end
+
+ stream_entry.target.tags.each do |tag|
+ category xml, tag
+ end
end
end
end
@@ -198,6 +206,10 @@ module AtomBuilderHelper
stream_entry.activity.media_attachments.each do |media|
link_enclosure xml, media
end
+
+ stream_entry.activity.tags.each do |tag|
+ category xml, tag
+ end
end
end
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
new file mode 100644
index 0000000000..23450bc5c5
--- /dev/null
+++ b/app/helpers/tags_helper.rb
@@ -0,0 +1,2 @@
+module TagsHelper
+end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 0b04ad7ffe..86f41cfe9f 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -23,8 +23,8 @@ class FeedManager
broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status))
end
- def broadcast(account_id, options = {})
- ActionCable.server.broadcast("timeline:#{account_id}", options)
+ def broadcast(timeline_id, options = {})
+ ActionCable.server.broadcast("timeline:#{timeline_id}", options)
end
def trim(type, account_id)
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index d8d5424fdb..1ec77e56dc 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -2,6 +2,7 @@ require 'singleton'
class Formatter
include Singleton
+ include RoutingHelper
include ActionView::Helpers::TextHelper
include ActionView::Helpers::SanitizeHelper
@@ -52,7 +53,7 @@ class Formatter
def hashtag_html(match)
prefix, affix = match.split('#')
- "#{prefix}##{affix}"
+ "#{prefix}##{affix}"
end
def mention_html(match, account)
diff --git a/app/models/status.rb b/app/models/status.rb
index c26e73d719..d68b7afa60 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -12,6 +12,7 @@ class Status < ApplicationRecord
has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
has_many :mentions, dependent: :destroy
has_many :media_attachments, dependent: :destroy
+ has_and_belongs_to_many :tags
validates :account, presence: true
validates :uri, uniqueness: true, unless: 'local?'
@@ -21,7 +22,7 @@ class Status < ApplicationRecord
default_scope { order('id desc') }
scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') }
- scope :with_includes, -> { includes(:account, :media_attachments, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
+ scope :with_includes, -> { includes(:account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
def local?
uri.nil?
@@ -85,29 +86,41 @@ class Status < ApplicationRecord
Account.where(id: favourites.limit(limit).pluck(:account_id)).with_counters
end
- def self.as_home_timeline(account)
- where(account: [account] + account.following).with_includes.with_counters
- end
-
- def self.as_mentions_timeline(account)
- where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
- end
-
- def self.as_public_timeline(account)
- joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id')
- .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
- .where('accounts.silenced = FALSE')
- .where('(reblogs.account_id IS NULL OR reblogs.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)) AND statuses.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)', account.id, account.id)
- .with_includes
- .with_counters
- end
-
- def self.favourites_map(status_ids, account_id)
- Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
- end
-
- def self.reblogs_map(status_ids, account_id)
- select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
+ class << self
+ def as_home_timeline(account)
+ where(account: [account] + account.following).with_includes.with_counters
+ end
+
+ def as_mentions_timeline(account)
+ where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
+ end
+
+ def as_public_timeline(account)
+ joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id')
+ .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
+ .where('accounts.silenced = FALSE')
+ .where('(reblogs.account_id IS NULL OR reblogs.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)) AND statuses.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)', account.id, account.id)
+ .with_includes
+ .with_counters
+ end
+
+ def as_tag_timeline(tag, account)
+ tag.statuses
+ .joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id')
+ .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
+ .where('accounts.silenced = FALSE')
+ .where('(reblogs.account_id IS NULL OR reblogs.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)) AND statuses.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)', account.id, account.id)
+ .with_includes
+ .with_counters
+ end
+
+ def favourites_map(status_ids, account_id)
+ Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
+ end
+
+ def reblogs_map(status_ids, account_id)
+ select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
+ end
end
before_validation do
diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb
index bc4821ca92..f8272be17c 100644
--- a/app/models/stream_entry.rb
+++ b/app/models/stream_entry.rb
@@ -10,7 +10,7 @@ class StreamEntry < ApplicationRecord
validates :account, :activity, presence: true
- STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]].freeze
+ STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]].freeze
scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES, favourite: [:account, :stream_entry, status: STATUS_INCLUDES], follow: [:target_account, :stream_entry]) }
diff --git a/app/models/tag.rb b/app/models/tag.rb
index a25785e08e..a5ee622631 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -1,5 +1,11 @@
class Tag < ApplicationRecord
+ has_and_belongs_to_many :statuses
+
HASHTAG_RE = /[?:^|\s|\.|>]#([[:word:]_]+)/i
validates :name, presence: true, uniqueness: true
+
+ def to_param
+ name
+ end
end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 707f74c35e..a36f80150a 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -5,6 +5,10 @@ class FanOutOnWriteService < BaseService
deliver_to_self(status) if status.account.local?
deliver_to_followers(status)
deliver_to_mentioned(status)
+
+ return if status.account.silenced?
+
+ deliver_to_hashtags(status)
deliver_to_public(status)
end
@@ -15,22 +19,27 @@ class FanOutOnWriteService < BaseService
end
def deliver_to_followers(status)
- status.account.followers.each do |follower|
+ status.account.followers.find_each do |follower|
next if !follower.local? || FeedManager.instance.filter?(:home, status, follower)
FeedManager.instance.push(:home, follower, status)
end
end
def deliver_to_mentioned(status)
- status.mentions.each do |mention|
+ status.mentions.find_each do |mention|
mentioned_account = mention.account
next if !mentioned_account.local? || mentioned_account.id == status.account_id || FeedManager.instance.filter?(:mentions, status, mentioned_account)
FeedManager.instance.push(:mentions, mentioned_account, status)
end
end
+ def deliver_to_hashtags(status)
+ status.tags.find_each do |tag|
+ FeedManager.instance.broadcast("hashtag:#{tag.name}", id: status.id)
+ end
+ end
+
def deliver_to_public(status)
- return if status.account.silenced?
FeedManager.instance.broadcast(:public, id: status.id)
end
end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 5cac6b70aa..b23808a7c1 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -9,6 +9,7 @@ class PostStatusService < BaseService
status = account.statuses.create!(text: text, thread: in_reply_to)
attach_media(status, media_ids)
process_mentions_service.call(status)
+ process_hashtags_service.call(status)
DistributionWorker.perform_async(status.id)
HubPingWorker.perform_async(account.id)
status
@@ -26,4 +27,8 @@ class PostStatusService < BaseService
def process_mentions_service
@process_mentions_service ||= ProcessMentionsService.new
end
+
+ def process_hashtags_service
+ @process_hashtags_service ||= ProcessHashtagsService.new
+ end
end
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index 2f53b9c77c..e60284d8e3 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -47,6 +47,12 @@ class ProcessFeedService < BaseService
record_remote_mentions(status, entry.xpath('./xmlns:link[@rel="mentioned"]'))
record_remote_mentions(status.reblog, entry.at_xpath('./activity:object', activity: ACTIVITY_NS).xpath('./xmlns:link[@rel="mentioned"]')) if status.reblog?
+ if status.reblog?
+ ProcessHashtagsService.new.call(status.reblog, entry.at_xpath('./activity:object', activity: ACTIVITY_NS).xpath('./xmlns:category').map { |category| category['term'] })
+ else
+ ProcessHashtagsService.new.call(status, entry.xpath('./xmlns:category').map { |category| category['term'] })
+ end
+
process_attachments(entry, status)
process_attachments(entry.xpath('./activity:object', activity: ACTIVITY_NS), status.reblog) if status.reblog?
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
new file mode 100644
index 0000000000..8c68ce9898
--- /dev/null
+++ b/app/services/process_hashtags_service.rb
@@ -0,0 +1,11 @@
+class ProcessHashtagsService < BaseService
+ def call(status, tags = [])
+ if status.local?
+ tags = status.text.scan(Tag::HASHTAG_RE).map(&:first)
+ end
+
+ tags.map(&:downcase).each do |tag|
+ status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
+ end
+ end
+end
diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl
index 00e6f64c17..3435d1039c 100644
--- a/app/views/api/v1/statuses/_show.rabl
+++ b/app/views/api/v1/statuses/_show.rabl
@@ -17,3 +17,7 @@ end
child :mentions, object_root: false do
extends 'api/v1/statuses/_mention'
end
+
+child :tags, object_root: false do
+ extends 'api/v1/statuses/_tags'
+end
diff --git a/app/views/api/v1/statuses/_tags.rabl b/app/views/api/v1/statuses/_tags.rabl
new file mode 100644
index 0000000000..25e7b0fac6
--- /dev/null
+++ b/app/views/api/v1/statuses/_tags.rabl
@@ -0,0 +1,2 @@
+attribute :name
+node(:url) { |tag| tag_url(tag) }
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/config/routes.rb b/config/routes.rb
index 4921d55f03..0a20d16552 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,6 +1,8 @@
require 'sidekiq/web'
Rails.application.routes.draw do
+ get 'tags/show'
+
mount ActionCable.server => '/cable'
authenticate :user, lambda { |u| u.admin? } do
@@ -40,6 +42,7 @@ Rails.application.routes.draw do
end
resources :media, only: [:show]
+ resources :tags, only: [:show]
namespace :api do
# PubSubHubbub
@@ -56,6 +59,7 @@ Rails.application.routes.draw do
get :home
get :mentions
get :public
+ get '/tag/:id', action: :tag
end
member do
diff --git a/db/migrate/20161105130633_create_statuses_tags_join_table.rb b/db/migrate/20161105130633_create_statuses_tags_join_table.rb
new file mode 100644
index 0000000000..8a436c6ea0
--- /dev/null
+++ b/db/migrate/20161105130633_create_statuses_tags_join_table.rb
@@ -0,0 +1,8 @@
+class CreateStatusesTagsJoinTable < ActiveRecord::Migration[5.0]
+ def change
+ create_join_table :statuses, :tags do |t|
+ t.index :tag_id
+ t.index [:tag_id, :status_id], unique: true
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 3d0182ba96..a2d05b1bd2 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: 20161104173623) do
+ActiveRecord::Schema.define(version: 20161105130633) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -160,6 +160,13 @@ ActiveRecord::Schema.define(version: 20161104173623) do
t.index ["uri"], name: "index_statuses_on_uri", unique: true, using: :btree
end
+ create_table "statuses_tags", id: false, force: :cascade do |t|
+ t.integer "status_id", null: false
+ t.integer "tag_id", null: false
+ t.index ["tag_id", "status_id"], name: "index_statuses_tags_on_tag_id_and_status_id", unique: true, using: :btree
+ t.index ["tag_id"], name: "index_statuses_tags_on_tag_id", using: :btree
+ end
+
create_table "stream_entries", force: :cascade do |t|
t.integer "account_id"
t.integer "activity_id"
diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb
index cf0b3649f8..9f9bb0c4f2 100644
--- a/spec/controllers/api/v1/statuses_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses_controller_spec.rb
@@ -80,6 +80,17 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
end
end
+ describe 'GET #tag' do
+ before do
+ post :create, params: { status: 'It is a #test' }
+ end
+
+ it 'returns http success' do
+ get :tag, params: { id: 'test' }
+ expect(response).to have_http_status(:success)
+ end
+ end
+
describe 'POST #create' do
before do
post :create, params: { status: 'Hello world' }
diff --git a/spec/controllers/tags_controller_spec.rb b/spec/controllers/tags_controller_spec.rb
new file mode 100644
index 0000000000..f433cf271c
--- /dev/null
+++ b/spec/controllers/tags_controller_spec.rb
@@ -0,0 +1,12 @@
+require 'rails_helper'
+
+RSpec.describe TagsController, type: :controller do
+
+ describe 'GET #show' do
+ it 'returns http success' do
+ get :show, params: { id: 'test' }
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+end
diff --git a/spec/helpers/tags_helper_spec.rb b/spec/helpers/tags_helper_spec.rb
new file mode 100644
index 0000000000..f661e44ac9
--- /dev/null
+++ b/spec/helpers/tags_helper_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe TagsHelper, type: :helper do
+
+end