From 29fd0cde7afd262543fb6c2424b81be280f100af Mon Sep 17 00:00:00 2001
From: noellabo <noel.yoshiba@gmail.com>
Date: Thu, 14 Nov 2019 07:42:56 +0900
Subject: [PATCH] Add a remote timeline

---
 .../api/v1/timelines/public_controller.rb     |   8 +-
 app/javascript/mastodon/actions/streaming.js  |   1 +
 app/javascript/mastodon/actions/timelines.js  |   1 +
 .../mastodon/components/status_action_bar.js  |   9 ++
 .../containers/column_settings_container.js   |  28 ++++
 .../features/domain_timeline/index.js         | 138 ++++++++++++++++++
 .../features/status/components/action_bar.js  |   9 ++
 .../features/ui/components/columns_area.js    |   2 +
 app/javascript/mastodon/features/ui/index.js  |   2 +
 .../features/ui/util/async-components.js      |   4 +
 app/javascript/mastodon/locales/en.json       |   1 +
 app/javascript/mastodon/locales/ja.json       |   1 +
 app/javascript/mastodon/reducers/settings.js  |   6 +
 app/models/status.rb                          |  10 ++
 app/services/batched_remove_status_service.rb |   8 +
 app/services/fan_out_on_write_service.rb      |   2 +
 app/services/remove_status_service.rb         |   2 +
 streaming/index.js                            |  32 ++++
 18 files changed, 262 insertions(+), 2 deletions(-)
 create mode 100644 app/javascript/mastodon/features/domain_timeline/containers/column_settings_container.js
 create mode 100644 app/javascript/mastodon/features/domain_timeline/index.js

diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index ccc10f966c..a59d9ecb75 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -41,7 +41,11 @@ class Api::V1::Timelines::PublicController < Api::BaseController
   end
 
   def public_timeline_statuses
-    Status.as_public_timeline(current_account, truthy_param?(:local))
+    if params[:domain].present?
+      Status.as_domain_timeline(current_account, params[:domain])
+    else
+      Status.as_public_timeline(current_account, truthy_param?(:local))
+    end
   end
 
   def insert_pagination_headers
@@ -49,7 +53,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
   end
 
   def pagination_params(core_params)
-    params.slice(:local, :limit, :only_media).permit(:local, :limit, :only_media).merge(core_params)
+    params.slice(:local, :domain, :limit, :only_media).permit(:local, :domain, :limit, :only_media).merge(core_params)
   end
 
   def next_path
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index c678e93932..1e6687d550 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -56,6 +56,7 @@ const refreshHomeTimelineAndNotification = (dispatch, done) => {
 
 export const connectUserStream      = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
 export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
+export const connectDomainStream    = (domain, { onlyMedia } = {}) => connectTimelineStream(`domain${onlyMedia ? ':media' : ''}:${domain}`, `public:remote${onlyMedia ? ':media' : ''}&domain=${domain}`);
 export const connectPublicStream    = ({ onlyMedia } = {}) => connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`);
 export const connectHashtagStream   = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
 export const connectDirectStream    = () => connectTimelineStream('direct', 'direct');
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index bc2ac5e823..ee54a082da 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -109,6 +109,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
 export const expandHomeTimeline            = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
 export const expandPublicTimeline          = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
 export const expandCommunityTimeline       = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
+export const expandDomainTimeline          = (domain, { maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`domain:${domain}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: false, domain: domain, max_id: maxId, only_media: !!onlyMedia }, done);
 export const expandAccountTimeline         = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
 export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
 export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 9acb8f9403..32b6ff2eb2 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -39,6 +39,7 @@ const messages = defineMessages({
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
   blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  openDomainTimeline: { id: 'account.open_domain_timeline', defaultMessage: 'Open {domain} timeline' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
 });
@@ -199,6 +200,13 @@ class StatusActionBar extends ImmutablePureComponent {
     onUnblockDomain(account.get('acct').split('@')[1]);
   }
 
+  handleOpenDomainTimeline = () => {
+    const { status } = this.props;
+    const account = status.get('account');
+
+    this.context.router.history.push(`/timelines/public/remote/${account.get('acct').split('@')[1]}`);
+  }
+
   handleOpen = () => {
     this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
   }
@@ -302,6 +310,7 @@ class StatusActionBar extends ImmutablePureComponent {
         } else {
           menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
         }
+        menu.push({ text: intl.formatMessage(messages.openDomainTimeline, { domain }), action: this.handleOpenDomainTimeline });
       }
 
       if (isStaff) {
diff --git a/app/javascript/mastodon/features/domain_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/domain_timeline/containers/column_settings_container.js
new file mode 100644
index 0000000000..a83d090306
--- /dev/null
+++ b/app/javascript/mastodon/features/domain_timeline/containers/column_settings_container.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../../community_timeline/components/column_settings';
+import { changeSetting } from '../../../actions/settings';
+import { changeColumnParams } from '../../../actions/columns';
+
+const mapStateToProps = (state, { columnId }) => {
+  const uuid = columnId;
+  const columns = state.getIn(['settings', 'columns']);
+  const index = columns.findIndex(c => c.get('uuid') === uuid);
+
+  return {
+    settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'domain']),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { columnId }) => {
+  return {
+    onChange (key, checked) {
+      if (columnId) {
+        dispatch(changeColumnParams(columnId, key, checked));
+      } else {
+        dispatch(changeSetting(['domain', ...key], checked));
+      }
+    },
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/domain_timeline/index.js b/app/javascript/mastodon/features/domain_timeline/index.js
new file mode 100644
index 0000000000..b5eb5d6ba6
--- /dev/null
+++ b/app/javascript/mastodon/features/domain_timeline/index.js
@@ -0,0 +1,138 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import { expandDomainTimeline } from '../../actions/timelines';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectDomainStream } from '../../actions/streaming';
+
+const mapStateToProps = (state, props) => {
+  const uuid = props.columnId;
+  const columns = state.getIn(['settings', 'columns']);
+  const index = columns.findIndex(c => c.get('uuid') === uuid);
+  const onlyMedia = (props.columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'domain', 'other', 'onlyMedia']);
+  const timelineState = state.getIn(['timelines', `domain:${domain}${onlyMedia ? ':media' : ''}`]);
+  const domain = props.params.domain;
+
+  return {
+    hasUnread: !!timelineState && timelineState.get('unread') > 0,
+    onlyMedia,
+    domain: domain,
+  };
+};
+
+export default @connect(mapStateToProps)
+@injectIntl
+class DomainTimeline extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static defaultProps = {
+    onlyMedia: false,
+  };
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    shouldUpdateScroll: PropTypes.func,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    onlyMedia: PropTypes.bool,
+    domain: PropTypes.string,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch, onlyMedia, domain } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DOMAIN', { domain, other: { onlyMedia } }));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch, onlyMedia, domain } = this.props;
+
+    dispatch(expandDomainTimeline(domain, { onlyMedia }));
+    this.disconnect = dispatch(connectDomainStream(domain, { onlyMedia }));
+  }
+
+  componentDidUpdate (prevProps) {
+    if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.domain !== this.props.domain) {
+      const { dispatch, onlyMedia, domain } = this.props;
+
+      this.disconnect();
+      dispatch(expandDomainTimeline(domain, { onlyMedia }));
+      this.disconnect = dispatch(connectDomainStream(domain, { onlyMedia }));
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = maxId => {
+    const { dispatch, onlyMedia, domain } = this.props;
+
+    dispatch(expandDomainTimeline(domain, { maxId, onlyMedia }));
+  }
+
+  render () {
+    const { shouldUpdateScroll, hasUnread, columnId, multiColumn, onlyMedia, domain } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={domain}>
+        <ColumnHeader
+          icon='users'
+          active={hasUnread}
+          title={domain}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          showBackButton
+        >
+          <ColumnSettingsContainer columnId={columnId} />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`domain_timeline-${columnId}`}
+          timelineId={`domain:${domain}${onlyMedia ? ':media' : ''}`}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.domain' defaultMessage='There is nothing here! Manually follow users from other servers to fill it up' />}
+          shouldUpdateScroll={shouldUpdateScroll}
+          bindToDocument={!multiColumn}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index b841ad3f9f..bcd8e343b4 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -34,6 +34,7 @@ const messages = defineMessages({
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
   blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  openDomainTimeline: { id: 'account.open_domain_timeline', defaultMessage: 'Open {domain} timeline' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
 });
@@ -146,6 +147,13 @@ class ActionBar extends React.PureComponent {
     onUnblockDomain(account.get('acct').split('@')[1]);
   }
 
+  handleOpenDomainTimeline = () => {
+    const { status } = this.props;
+    const account = status.get('account');
+
+    this.context.router.history.push(`/timelines/public/remote/${account.get('acct').split('@')[1]}`);
+  }
+
   handleConversationMuteClick = () => {
     this.props.onMuteConversation(this.props.status);
   }
@@ -246,6 +254,7 @@ class ActionBar extends React.PureComponent {
         } else {
           menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
         }
+        menu.push({ text: intl.formatMessage(messages.openDomainTimeline, { domain }), action: this.handleOpenDomainTimeline });
       }
 
       if (isStaff) {
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index e7ff17fcf3..bcbd8cd858 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -18,6 +18,7 @@ import {
   HomeTimeline,
   CommunityTimeline,
   PublicTimeline,
+  DomainTimeline,
   HashtagTimeline,
   DirectTimeline,
   FavouritedStatuses,
@@ -38,6 +39,7 @@ const componentMap = {
   'NOTIFICATIONS': Notifications,
   'PUBLIC': PublicTimeline,
   'COMMUNITY': CommunityTimeline,
+  'DOMAIN': DomainTimeline,
   'HASHTAG': HashtagTimeline,
   'DIRECT': DirectTimeline,
   'FAVOURITES': FavouritedStatuses,
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 4acf9ee066..f0bcde9dff 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -28,6 +28,7 @@ import {
   KeyboardShortcuts,
   PublicTimeline,
   CommunityTimeline,
+  DomainTimeline,
   AccountTimeline,
   AccountGallery,
   HomeTimeline,
@@ -184,6 +185,7 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
           <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
           <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
+          <WrappedRoute path='/timelines/public/remote/:domain' exact component={DomainTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
           <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
           <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
           <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 986efda1ec..bf9e87e174 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -22,6 +22,10 @@ export function CommunityTimeline () {
   return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline');
 }
 
+export function DomainTimeline () {
+  return import(/* webpackChunkName: "features/domain_timeline" */'../../domain_timeline');
+}
+
 export function HashtagTimeline () {
   return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
 }
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 7d9bafd626..138b67f6fb 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -27,6 +27,7 @@
   "account.mute_notifications": "Mute notifications from @{name}",
   "account.muted": "Muted",
   "account.never_active": "Never",
+  "account.open_domain_timeline": "Open {domain} timeline",
   "account.posts": "Toots",
   "account.posts_with_replies": "Toots and replies",
   "account.report": "Report @{name}",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 0252a5525a..86a3a9d7a8 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -27,6 +27,7 @@
   "account.mute_notifications": "@{name}さんからの通知を受け取らない",
   "account.muted": "ミュート済み",
   "account.never_active": "活動なし",
+  "account.open_domain_timeline": "{domain}タイムラインを表示",
   "account.posts": "投稿",
   "account.posts_with_replies": "投稿と返信",
   "account.report": "@{name}さんを通報",
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index efef2ad9a5..f0461eedd1 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -68,6 +68,12 @@ const initialState = ImmutableMap({
     }),
   }),
 
+  domain: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
   public: ImmutableMap({
     regex: ImmutableMap({
       body: '',
diff --git a/app/models/status.rb b/app/models/status.rb
index f7c551e811..aeb6bf0ca5 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -307,6 +307,16 @@ class Status < ApplicationRecord
       apply_timeline_filters(query, account, local_only)
     end
 
+    def as_domain_timeline(account = nil, domain)
+      query = Status.includes(:account)
+        .where(accounts: {domain: domain}).select('statuses.*, accounts.*')
+        .with_public_visibility
+        .without_reblogs
+        .without_replies
+
+      apply_timeline_filters(query, account, false)
+    end
+
     def as_tag_timeline(tag, account = nil, local_only = false)
       query = timeline_scope(local_only).tagged_with(tag)
 
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index 3638134be7..fcf20d1c5f 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -16,6 +16,7 @@ class BatchedRemoveStatusService < BaseService
 
     @mentions = statuses.each_with_object({}) { |s, h| h[s.id] = s.active_mentions.includes(:account).to_a }
     @tags     = statuses.each_with_object({}) { |s, h| h[s.id] = s.tags.pluck(:name) }
+    @domains  = statuses.each_with_object({}) { |s, h| h[s.id] = s.account.domain unless s.local? }
 
     @json_payloads = statuses.each_with_object({}) { |s, h| h[s.id] = Oj.dump(event: :delete, payload: s.id.to_s) }
 
@@ -74,9 +75,16 @@ class BatchedRemoveStatusService < BaseService
       redis.publish('timeline:public', payload)
       redis.publish('timeline:public:local', payload) if status.local?
 
+      @domains[status.id].each do |domain|
+        redis.publish("timeline:public:remote:#{domain.mb_chars.downcase}", payload)
+      end
+
       if status.media_attachments.any?
         redis.publish('timeline:public:media', payload)
         redis.publish('timeline:public:local:media', payload) if status.local?
+        @domains[status.id].each do |domain|
+          redis.publish("timeline:public:remote:media:#{domain.mb_chars.downcase}", payload)
+        end
       end
 
       @tags[status.id].each do |hashtag|
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 77b11a80df..db4a37e454 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -167,12 +167,14 @@ class FanOutOnWriteService < BaseService
     Rails.logger.debug "Delivering status #{status.id} to public timeline"
 
     Redis.current.publish('timeline:public', @payload)
+    Redis.current.publish("timeline:public:remote:#{status.account.domain.mb_chars.downcase}", @payload) unless status.local?
   end
 
   def deliver_to_media(status)
     Rails.logger.debug "Delivering status #{status.id} to media timeline"
 
     Redis.current.publish('timeline:public:media', @payload)
+    Redis.current.publish("timeline:public:remote:media:#{status.account.domain.mb_chars.downcase}", @payload) unless status.local?
   end
 
   def deliver_to_own_conversation(status)
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index f9352ed3d3..887566b9e8 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -141,6 +141,7 @@ class RemoveStatusService < BaseService
 
     redis.publish('timeline:public', @payload)
     redis.publish('timeline:public:local', @payload) if @status.local?
+    redis.publish("timeline:public:remote:#{@account.domain.mb_chars.downcase}", @payload) unless @status.local?
   end
 
   def remove_from_media
@@ -148,6 +149,7 @@ class RemoveStatusService < BaseService
 
     redis.publish('timeline:public:media', @payload)
     redis.publish('timeline:public:local:media', @payload) if @status.local?
+    redis.publish("timeline:public:remote:media:#{@account.domain.mb_chars.downcase}", @payload) unless @status.local?
   end
 
   def remove_media
diff --git a/streaming/index.js b/streaming/index.js
index 304e7e0465..2ac0f8b27f 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -266,6 +266,8 @@ const startWorker = (workerId) => {
     'public:media',
     'public:local',
     'public:local:media',
+    'public:remote',
+    'public:remote:media',
     'hashtag',
     'hashtag:local',
   ];
@@ -297,6 +299,7 @@ const startWorker = (workerId) => {
   const PUBLIC_ENDPOINTS = [
     '/api/v1/streaming/public',
     '/api/v1/streaming/public/local',
+    '/api/v1/streaming/public/remote',
     '/api/v1/streaming/hashtag',
     '/api/v1/streaming/hashtag/local',
   ];
@@ -532,6 +535,19 @@ const startWorker = (workerId) => {
     streamFrom(channel, req, streamToHttp(req, res), streamHttpEnd(req), true);
   });
 
+  app.get('/api/v1/streaming/public/remote', (req, res) => {
+    const onlyMedia  = req.query.only_media === '1' || req.query.only_media === 'true';
+    const channel    = onlyMedia ? 'timeline:public:remote:media' : 'timeline:public:remote';
+    const { domain } = req.query;
+
+    if (!domain || domain.length === 0) {
+      httpNotFound(res);
+      return;
+    }
+
+    streamFrom(`${channel}:${domain.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
+  });
+
   app.get('/api/v1/streaming/direct', (req, res) => {
     const channel = `timeline:direct:${req.accountId}`;
     streamFrom(channel, req, streamToHttp(req, res), streamHttpEnd(req, subscriptionHeartbeat(channel)), true);
@@ -596,12 +612,28 @@ const startWorker = (workerId) => {
     case 'public:local':
       streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
       break;
+    case 'public:remote':
+      if (!location.query.domain || location.query.domain.length === 0) {
+        ws.close();
+        return;
+      }
+
+      streamFrom(`timeline:public:remote:${location.query.domain.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
+      break;
     case 'public:media':
       streamFrom('timeline:public:media', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
       break;
     case 'public:local:media':
       streamFrom('timeline:public:local:media', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
       break;
+    case 'public:remote:media':
+      if (!location.query.domain || location.query.domain.length === 0) {
+        ws.close();
+        return;
+      }
+
+      streamFrom(`timeline:public:remote:media:${location.query.domain.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
+      break;
     case 'direct':
       channel = `timeline:direct:${req.accountId}`;
       streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)), true);
-- 
GitLab