about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/components/actions/timelines.jsx24
-rw-r--r--app/assets/javascripts/components/components/status_content.jsx14
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx2
-rw-r--r--app/assets/javascripts/components/features/account/index.jsx2
-rw-r--r--app/assets/javascripts/components/features/hashtag_timeline/index.jsx72
-rw-r--r--app/assets/javascripts/components/features/ui/containers/status_list_container.jsx11
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx9
-rw-r--r--app/channels/application_cable/channel.rb13
-rw-r--r--app/channels/hashtag_channel.rb11
-rw-r--r--app/channels/public_channel.rb14
-rw-r--r--app/channels/timeline_channel.rb4
-rw-r--r--app/controllers/api/v1/statuses_controller.rb13
-rw-r--r--app/controllers/tags_controller.rb4
-rw-r--r--app/helpers/atom_builder_helper.rb12
-rw-r--r--app/helpers/tags_helper.rb2
-rw-r--r--app/lib/feed_manager.rb4
-rw-r--r--app/lib/formatter.rb3
-rw-r--r--app/models/status.rb61
-rw-r--r--app/models/stream_entry.rb2
-rw-r--r--app/models/tag.rb6
-rw-r--r--app/services/fan_out_on_write_service.rb15
-rw-r--r--app/services/post_status_service.rb5
-rw-r--r--app/services/process_feed_service.rb6
-rw-r--r--app/services/process_hashtags_service.rb11
-rw-r--r--app/views/api/v1/statuses/_show.rabl4
-rw-r--r--app/views/api/v1/statuses/_tags.rabl2
-rw-r--r--app/views/tags/show.html.haml0
-rw-r--r--config/routes.rb4
-rw-r--r--db/migrate/20161105130633_create_statuses_tags_join_table.rb8
-rw-r--r--db/schema.rb9
-rw-r--r--spec/controllers/api/v1/statuses_controller_spec.rb11
-rw-r--r--spec/controllers/tags_controller_spec.rb12
-rw-r--r--spec/helpers/tags_helper_spec.rb5
33 files changed, 309 insertions, 66 deletions
diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 01eee1712..1dd770848 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 357465248..2006e965a 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 bf92e248d..f29893ec0 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({
             <Route path='/statuses/home' component={HomeTimeline} />
             <Route path='/statuses/mentions' component={MentionsTimeline} />
             <Route path='/statuses/all' component={PublicTimeline} />
+            <Route path='/statuses/tag/:id' component={HashtagTimeline} />
 
             <Route path='/statuses/:statusId' component={Status} />
             <Route path='/statuses/:statusId/reblogs' component={Reblogs} />
diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx
index 6cadcff4d..818979f8f 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 000000000..de6a9618e
--- /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 (
+      <Column icon='hashtag' heading={id}>
+        <StatusListContainer type='tag' id={id} />
+      </Column>
+    );
+  },
+
+});
+
+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 213435a06..8004e3f04 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 c12d1b70d..9e79a4100 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 d67269728..d27b058fb 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 000000000..5be8d94cd
--- /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 708eff055..41e21611d 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 9e5a81188..f2a9636fd 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 b05a27ef4..0a823e3e6 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 000000000..c1aaf7e47
--- /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 c7131074d..2eed2da65 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 000000000..23450bc5c
--- /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 0b04ad7ff..86f41cfe9 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 d8d5424fd..1ec77e56d 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}<a href=\"#\" class=\"mention hashtag\">#<span>#{affix}</span></a>"
+    "#{prefix}<a href=\"#{tag_url(affix.downcase)}\" class=\"mention hashtag\">#<span>#{affix}</span></a>"
   end
 
   def mention_html(match, account)
diff --git a/app/models/status.rb b/app/models/status.rb
index c26e73d71..d68b7afa6 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 bc4821ca9..f8272be17 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 a25785e08..a5ee62263 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 707f74c35..a36f80150 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 5cac6b70a..b23808a7c 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 2f53b9c77..e60284d8e 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 000000000..8c68ce989
--- /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 00e6f64c1..3435d1039 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 000000000..25e7b0fac
--- /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 000000000..e69de29bb
--- /dev/null
+++ b/app/views/tags/show.html.haml
diff --git a/config/routes.rb b/config/routes.rb
index 4921d55f0..0a20d1655 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 000000000..8a436c6ea
--- /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 3d0182ba9..a2d05b1bd 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 cf0b3649f..9f9bb0c4f 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 000000000..f433cf271
--- /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 000000000..f661e44ac
--- /dev/null
+++ b/spec/helpers/tags_helper_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe TagsHelper, type: :helper do
+
+end