From bc2eeee4971285955813c05270f9542aa5c2a2f7 Mon Sep 17 00:00:00 2001
From: mgabdev <>
Date: Wed, 1 Jul 2020 21:40:00 -0400
Subject: [PATCH] Added pro feed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
• Added:
- pro feed
---
.../api/v1/timelines/pro_controller.rb | 66 +++++++++++++++++++
app/javascript/gabsocial/actions/streaming.js | 1 +
app/javascript/gabsocial/actions/timelines.js | 1 +
.../gabsocial/components/sidebar.js | 5 ++
.../gabsocial/components/sidebar_xs.js | 19 ++++--
.../gabsocial/features/pro_timeline.js | 60 +++++++++++++++++
app/javascript/gabsocial/features/ui/ui.js | 3 +
.../features/ui/util/async_components.js | 1 +
app/javascript/gabsocial/pages/pro_page.js | 43 ++++++++++++
app/models/status.rb | 8 ++-
app/services/batched_remove_status_service.rb | 4 ++
app/services/fan_out_on_write_service.rb | 11 ++++
app/services/remove_status_service.rb | 7 ++
config/routes.rb | 1 +
streaming/index.js | 9 ++-
15 files changed, 230 insertions(+), 9 deletions(-)
create mode 100644 app/controllers/api/v1/timelines/pro_controller.rb
create mode 100644 app/javascript/gabsocial/features/pro_timeline.js
create mode 100644 app/javascript/gabsocial/pages/pro_page.js
diff --git a/app/controllers/api/v1/timelines/pro_controller.rb b/app/controllers/api/v1/timelines/pro_controller.rb
new file mode 100644
index 00000000..89abc4f1
--- /dev/null
+++ b/app/controllers/api/v1/timelines/pro_controller.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+class Api::V1::Timelines::ProController < Api::BaseController
+ before_action :require_user!, only: [:show]
+ after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
+
+ respond_to :json
+
+ def show
+ @statuses = load_statuses
+ render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ end
+
+ private
+
+ def load_statuses
+ cached_pro_statuses
+ end
+
+ def cached_pro_statuses
+ cache_collection pro_statuses, Status
+ end
+
+ def pro_statuses
+ statuses = pro_timeline_statuses.paginate_by_id(
+ limit_param(DEFAULT_STATUSES_LIMIT),
+ params_slice(:max_id, :since_id, :min_id)
+ )
+
+ if truthy_param?(:only_media)
+ # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
+ status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id)
+ statuses.where(id: status_ids)
+ else
+ statuses
+ end
+ end
+
+ def pro_timeline_statuses
+ Status.as_pro_timeline(current_account)
+ end
+
+ def insert_pagination_headers
+ set_pagination_headers(next_path, prev_path)
+ end
+
+ def pagination_params(core_params)
+ params.slice(:limit, :only_media).permit(:limit, :only_media).merge(core_params)
+ end
+
+ def next_path
+ api_v1_timelines_pro_url pagination_params(max_id: pagination_max_id)
+ end
+
+ def prev_path
+ api_v1_timelines_pro_url pagination_params(min_id: pagination_since_id)
+ end
+
+ def pagination_max_id
+ @statuses.last.id
+ end
+
+ def pagination_since_id
+ @statuses.first.id
+ end
+end
diff --git a/app/javascript/gabsocial/actions/streaming.js b/app/javascript/gabsocial/actions/streaming.js
index 8b57cb2c..880acec4 100644
--- a/app/javascript/gabsocial/actions/streaming.js
+++ b/app/javascript/gabsocial/actions/streaming.js
@@ -51,6 +51,7 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
}
export const connectUserStream = () => connectTimelineStream('home', 'user');
+export const connectProStream = () => connectTimelineStream('pro', 'pro');
export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
export const connectGroupStream = id => connectTimelineStream(`group:${id}`, `group&group=${id}`);
diff --git a/app/javascript/gabsocial/actions/timelines.js b/app/javascript/gabsocial/actions/timelines.js
index 80dc04e5..3bbb099b 100644
--- a/app/javascript/gabsocial/actions/timelines.js
+++ b/app/javascript/gabsocial/actions/timelines.js
@@ -166,6 +166,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 expandProTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('pro', '/api/v1/timelines/pro', { max_id: maxId }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies, commentsOnly } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${commentsOnly ? ':comments_only' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { only_comments: commentsOnly, exclude_replies: (!withReplies && !commentsOnly), max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
diff --git a/app/javascript/gabsocial/components/sidebar.js b/app/javascript/gabsocial/components/sidebar.js
index 769bc64d..931d77d0 100644
--- a/app/javascript/gabsocial/components/sidebar.js
+++ b/app/javascript/gabsocial/components/sidebar.js
@@ -164,6 +164,11 @@ class Sidebar extends ImmutablePureComponent {
]
const exploreItems = [
+ {
+ title: 'Pro Feed',
+ icon: 'circle',
+ to: '/timeline/pro',
+ },
{
title: 'Chat',
icon: 'chat',
diff --git a/app/javascript/gabsocial/components/sidebar_xs.js b/app/javascript/gabsocial/components/sidebar_xs.js
index a8949a97..74554215 100644
--- a/app/javascript/gabsocial/components/sidebar_xs.js
+++ b/app/javascript/gabsocial/components/sidebar_xs.js
@@ -39,6 +39,7 @@ const messages = defineMessages({
chat: { id: 'tabs_bar.chat', defaultMessage: 'Chat' },
help: { id: 'getting_started.help', defaultMessage: 'Help' },
display: { id: 'display_options', defaultMessage: 'Display Options' },
+ pro: { id: 'pro_feed', defaultMessage: 'Pro Feed' },
})
const mapStateToProps = (state) => ({
@@ -94,6 +95,12 @@ class SidebarXS extends ImmutablePureComponent {
onClick: this.handleSidebarClose,
title: intl.formatMessage(messages.profile),
},
+ {
+ icon: 'list',
+ to: '/lists',
+ onClick: this.handleSidebarClose,
+ title: intl.formatMessage(messages.lists),
+ },
{
icon: 'pro',
href: 'https://pro.gab.com',
@@ -124,18 +131,18 @@ class SidebarXS extends ImmutablePureComponent {
onClick: this.handleSidebarClose,
title: intl.formatMessage(messages.search),
},
+ {
+ icon: 'circle',
+ to: '/timeline/pro',
+ onClick: this.handleSidebarClose,
+ title: intl.formatMessage(messages.pro),
+ },
{
icon: 'cog',
href: '/settings/preferences',
onClick: this.handleSidebarClose,
title: intl.formatMessage(messages.preferences),
},
- {
- icon: 'list',
- to: '/lists',
- onClick: this.handleSidebarClose,
- title: intl.formatMessage(messages.lists),
- },
// {
// icon: 'group',
// to: '/follow_requests',
diff --git a/app/javascript/gabsocial/features/pro_timeline.js b/app/javascript/gabsocial/features/pro_timeline.js
new file mode 100644
index 00000000..bc0d6aa3
--- /dev/null
+++ b/app/javascript/gabsocial/features/pro_timeline.js
@@ -0,0 +1,60 @@
+import { defineMessages, injectIntl } from 'react-intl'
+import { expandProTimeline } from '../actions/timelines'
+import { connectProStream } from '../actions/streaming'
+import StatusList from '../components/status_list'
+
+const messages = defineMessages({
+ empty: { id: 'empty_column.pro', defaultMessage: 'The pro timeline is empty.' },
+})
+
+export default
+@injectIntl
+@connect(null)
+class ProTimeline extends PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ }
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ }
+
+ componentDidMount () {
+ const { dispatch } = this.props
+
+ dispatch(expandProTimeline())
+
+ this.disconnect = dispatch(connectProStream())
+ }
+
+ componentWillUnmount() {
+ if (this.disconnect) {
+ this.disconnect()
+ this.disconnect = null
+ }
+ }
+
+ handleLoadMore = (maxId) => {
+ const { dispatch } = this.props
+
+ dispatch(expandProTimeline({ maxId }))
+ }
+
+ render () {
+ const { intl } = this.props
+
+ const emptyMessage = intl.formatMessage(messages.empty)
+
+ return (
+
+ )
+ }
+
+}
diff --git a/app/javascript/gabsocial/features/ui/ui.js b/app/javascript/gabsocial/features/ui/ui.js
index 62981660..70f80d6f 100644
--- a/app/javascript/gabsocial/features/ui/ui.js
+++ b/app/javascript/gabsocial/features/ui/ui.js
@@ -36,6 +36,7 @@ import ListsPage from '../../pages/lists_page'
import BasicPage from '../../pages/basic_page'
import ModalPage from '../../pages/modal_page'
import SettingsPage from '../../pages/settings_page'
+import ProPage from '../../pages/pro_page'
import {
AccountGallery,
@@ -64,6 +65,7 @@ import {
ListTimeline,
Mutes,
Notifications,
+ ProTimeline,
Search,
// Shortcuts,
StatusFeature,
@@ -151,6 +153,7 @@ class SwitchingArea extends PureComponent {
+
diff --git a/app/javascript/gabsocial/features/ui/util/async_components.js b/app/javascript/gabsocial/features/ui/util/async_components.js
index 06363510..e0c77b52 100644
--- a/app/javascript/gabsocial/features/ui/util/async_components.js
+++ b/app/javascript/gabsocial/features/ui/util/async_components.js
@@ -55,6 +55,7 @@ export function Mutes() { return import(/* webpackChunkName: "features/mutes" */
export function MuteModal() { return import(/* webpackChunkName: "modals/mute_modal" */'../../../components/modal/mute_modal') }
export function NavSettingsPopover() { return import(/* webpackChunkName: "modals/nav_settings_popover" */'../../../components/popover/nav_settings_popover') }
export function Notifications() { return import(/* webpackChunkName: "features/notifications" */'../../notifications') }
+export function ProTimeline() { return import(/* webpackChunkName: "features/pro_timeline" */'../../pro_timeline') }
export function ProfileOptionsPopover() { return import(/* webpackChunkName: "components/profile_options_popover" */'../../../components/popover/profile_options_popover') }
export function ProUpgradeModal() { return import(/* webpackChunkName: "components/pro_upgrade_modal" */'../../../components/modal/pro_upgrade_modal') }
export function ReportModal() { return import(/* webpackChunkName: "modals/report_modal" */'../../../components/modal/report_modal') }
diff --git a/app/javascript/gabsocial/pages/pro_page.js b/app/javascript/gabsocial/pages/pro_page.js
new file mode 100644
index 00000000..7168f7b7
--- /dev/null
+++ b/app/javascript/gabsocial/pages/pro_page.js
@@ -0,0 +1,43 @@
+import { Fragment } from 'react'
+import { defineMessages, injectIntl } from 'react-intl'
+import PageTitle from '../features/ui/util/page_title'
+import LinkFooter from '../components/link_footer'
+import VerifiedAccountsPanel from '../components/panel/verified_accounts_panel'
+import ProgressPanel from '../components/panel/progress_panel'
+import DefaultLayout from '../layouts/default_layout'
+
+const messages = defineMessages({
+ title: { 'id': 'column.pro', 'defaultMessage': 'Pro feed' },
+})
+
+export default
+@injectIntl
+class ProPage extends PureComponent {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ children: PropTypes.node.isRequired,
+ }
+
+ render() {
+ const { intl, children } = this.props
+
+ const title = intl.formatMessage(messages.title)
+
+ return (
+
+
+
+
+
+ )}
+ >
+
+ {children}
+
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/models/status.rb b/app/models/status.rb
index 1f66138f..85a05690 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -96,6 +96,7 @@ class Status < ApplicationRecord
scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
+ scope :popular_accounts, -> { left_outer_joins(:account).where('accounts.is_investor=true OR accounts.is_donor=true OR accounts.is_verified=true OR accounts.is_pro=true') }
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) }
scope :tagged_with_all, ->(tags) {
@@ -341,8 +342,13 @@ class Status < ApplicationRecord
end
end
+ def as_pro_timeline(account = nil)
+ query = timeline_scope.without_replies.popular_accounts.where('statuses.created_at > ?', 2.days.ago)
+ apply_timeline_filters(query, account)
+ end
+
def as_public_timeline(account = nil)
- query = timeline_scope.without_replies
+ query = timeline_scope.without_replies.where('statuses.created_at > ?', 15.minutes.ago)
apply_timeline_filters(query, account)
end
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index 85a2f036..6bcd3cb8 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -87,6 +87,10 @@ class BatchedRemoveStatusService < BaseService
payload = @json_payloads[status.id]
redis.pipelined do
+ if status.account.is_pro || status.account.is_donor || status.account.is_investor || status.account.is_verified
+ redis.publish('timeline:pro', payload)
+ end
+
@tags[status.id].each do |hashtag|
redis.publish("timeline:hashtag:#{hashtag}", payload)
redis.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 63c517ca..814e7a68 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -24,6 +24,11 @@ class FanOutOnWriteService < BaseService
deliver_to_hashtags(status)
return if status.reply? && status.in_reply_to_account_id != status.account_id
+
+
+ if status.account.is_pro || status.account.is_donor || status.account.is_investor || status.account.is_verified
+ deliver_to_pro(status)
+ end
end
private
@@ -95,6 +100,12 @@ class FanOutOnWriteService < BaseService
end
end
+ def deliver_to_pro(status)
+ Rails.logger.debug "Delivering status #{status.id} to pro timeline"
+
+ Redis.current.publish('timeline:pro', @payload)
+ end
+
def deliver_to_own_conversation(status)
AccountConversation.add_status(status.account, status)
end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 61d56400..43d9787d 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -22,6 +22,7 @@ class RemoveStatusService < BaseService
remove_from_affected
remove_reblogs
remove_from_hashtags
+ remove_from_pro
@status.destroy!
else
@@ -143,6 +144,12 @@ class RemoveStatusService < BaseService
end
end
+ def remove_from_pro
+ if @account.is_pro || @account.is_donor || @account.is_investor || @account.is_verified
+ redis.publish('timeline:pro', @payload)
+ end
+ end
+
def lock_options
{ redis: Redis.current, key: "distribute:#{@status.id}" }
end
diff --git a/config/routes.rb b/config/routes.rb
index 5c6c2a94..b14f1c66 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -321,6 +321,7 @@ Rails.application.routes.draw do
resource :direct, only: :show, controller: :direct
resource :home, only: :show, controller: :home
resource :public, only: :show, controller: :public
+ resource :pro, only: :show, controller: :pro
resources :tag, only: :show
resources :list, only: :show
resources :group, only: :show
diff --git a/streaming/index.js b/streaming/index.js
index 94bea582..22094852 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -296,8 +296,6 @@ const startWorker = (workerId) => {
};
const PUBLIC_ENDPOINTS = [
- '/api/v1/streaming/public',
- '/api/v1/streaming/public/local',
'/api/v1/streaming/hashtag',
'/api/v1/streaming/hashtag/local',
];
@@ -585,6 +583,10 @@ const startWorker = (workerId) => {
});
});
+ app.get('/api/v1/streaming/pro', (req, res) => {
+ streamFrom('timeline:pro', req, streamToHttp(req, res), streamHttpEnd(req, subscriptionHeartbeat(channel)));
+ });
+
const wss = new WebSocketServer({ server, verifyClient: wsVerifyClient });
wss.on('connection', (ws, req) => {
@@ -595,6 +597,9 @@ const startWorker = (workerId) => {
let channel;
switch (location.query.stream) {
+ case 'pro':
+ streamFrom('timeline:pro', req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)));
+ break;
case 'statuscard':
channel = `statuscard:${req.accountId}`;
streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)));