Added pro feed

• Added:
- pro feed
This commit is contained in:
mgabdev 2020-07-01 21:40:00 -04:00
parent 7410c4c6ab
commit bc2eeee497
15 changed files with 230 additions and 9 deletions

View File

@ -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

View File

@ -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}`);

View File

@ -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 });

View File

@ -164,6 +164,11 @@ class Sidebar extends ImmutablePureComponent {
]
const exploreItems = [
{
title: 'Pro Feed',
icon: 'circle',
to: '/timeline/pro',
},
{
title: 'Chat',
icon: 'chat',

View File

@ -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',

View File

@ -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 (
<StatusList
scrollKey='pro_timeline'
timelineId='pro'
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
/>
)
}
}

View File

@ -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 {
<WrappedRoute path='/compose' exact page={BasicPage} component={Compose} content={children} componentParams={{ title: 'Compose' }} />
<WrappedRoute path='/timeline/all' exact page={CommunityPage} component={CommunityTimeline} content={children} componentParams={{ title: 'Community Feed' }} />
<WrappedRoute path='/timeline/pro' exact page={ProPage} component={ProTimeline} content={children} componentParams={{ title: 'Pro Feed' }} />
<WrappedRoute path='/groups' exact page={GroupsPage} component={GroupsCollection} content={children} componentParams={{ activeTab: 'featured' }} />
<WrappedRoute path='/groups/new' exact page={GroupsPage} component={GroupsCollection} content={children} componentParams={{ activeTab: 'new' }} />

View File

@ -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') }

View File

@ -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 (
<DefaultLayout
title={title}
layout={(
<Fragment>
<ProgressPanel />
<VerifiedAccountsPanel />
<LinkFooter />
</Fragment>
)}
>
<PageTitle path={title} />
{children}
</DefaultLayout>
)
}
}

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)));