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