From 5c758b344c727991a4dac1bc3d49d7651c211f8a Mon Sep 17 00:00:00 2001 From: KMY Date: Sat, 19 Aug 2023 17:25:55 +0900 Subject: [PATCH] Add antenna timeline --- .../api/v1/timelines/antenna_controller.rb | 67 ++++++ app/javascript/mastodon/actions/streaming.js | 8 + app/javascript/mastodon/actions/timelines.js | 2 + .../features/antenna_timeline/index.jsx | 203 ++++++++++++++++++ .../mastodon/features/antennas/index.jsx | 2 +- .../mastodon/features/list_timeline/index.jsx | 2 +- app/javascript/mastodon/features/ui/index.jsx | 2 + .../features/ui/util/async-components.js | 4 + app/lib/feed_manager.rb | 15 ++ app/models/antenna_feed.rb | 7 + app/services/fan_out_on_write_service.rb | 25 +-- app/workers/feed_insert_worker.rb | 10 + config/routes.rb | 1 + config/routes/api.rb | 1 + streaming/index.js | 40 ++++ 15 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 app/controllers/api/v1/timelines/antenna_controller.rb create mode 100644 app/javascript/mastodon/features/antenna_timeline/index.jsx create mode 100644 app/models/antenna_feed.rb diff --git a/app/controllers/api/v1/timelines/antenna_controller.rb b/app/controllers/api/v1/timelines/antenna_controller.rb new file mode 100644 index 0000000000..29322e44ae --- /dev/null +++ b/app/controllers/api/v1/timelines/antenna_controller.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::AntennaController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:lists' } + before_action :require_user! + before_action :set_antenna + before_action :set_statuses + + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + def show + render json: @statuses, + each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) + end + + private + + def set_antenna + @antenna = Antenna.where(account: current_account).find(params[:id]) + end + + def set_statuses + @statuses = cached_list_statuses + end + + def cached_list_statuses + cache_collection list_statuses, Status + end + + def list_statuses + list_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id], + params[:min_id] + ) + end + + def list_feed + AntennaFeed.new(@antenna) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def next_path + api_v1_timelines_antenna_url params[:id], pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_antenna_url params[:id], 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/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index cdf6c82d1b..262d055448 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -22,6 +22,7 @@ import { fillPublicTimelineGaps, fillCommunityTimelineGaps, fillListTimelineGaps, + fillAntennaTimelineGaps, } from './timelines'; /** @@ -185,3 +186,10 @@ export const connectDirectStream = () => */ export const connectListStream = listId => connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) }); + +/** + * @param {string} antennaId + * @returns {function(): void} + */ +export const connectAntennaStream = antennaId => +connectTimelineStream(`antenna:${antennaId}`, 'antenna', { antenna: antennaId }, { fillGaps: () => fillAntennaTimelineGaps(antennaId) }); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 08561c71f4..4e246a39dc 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -149,6 +149,7 @@ export const expandAccountTimeline = (accountId, { maxId, withReplies, t export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); +export const expandAntennaTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`antenna:${id}`, `/api/v1/timelines/antenna/${id}`, { max_id: maxId }, done); export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, @@ -163,6 +164,7 @@ export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia }, done); export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done); export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done); +export const fillAntennaTimelineGaps = (id, done = noOp) => fillTimelineGaps(`antenna:${id}`, `/api/v1/timelines/antenna/${id}`, {}, done); export function expandTimelineRequest(timeline, isLoadingMore) { return { diff --git a/app/javascript/mastodon/features/antenna_timeline/index.jsx b/app/javascript/mastodon/features/antenna_timeline/index.jsx new file mode 100644 index 0000000000..03db96fefc --- /dev/null +++ b/app/javascript/mastodon/features/antenna_timeline/index.jsx @@ -0,0 +1,203 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { fetchAntenna, deleteAntenna } from 'mastodon/actions/antennas'; +import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; +import { openModal } from 'mastodon/actions/modal'; +import { connectAntennaStream } from 'mastodon/actions/streaming'; +import { expandAntennaTimeline } from 'mastodon/actions/timelines'; +import Column from 'mastodon/components/column'; +import ColumnHeader from 'mastodon/components/column_header'; +import { Icon } from 'mastodon/components/icon'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; +import StatusListContainer from 'mastodon/features/ui/containers/status_list_container'; + +const messages = defineMessages({ + deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, + deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, + followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' }, + none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' }, + antenna: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' }, +}); + +const mapStateToProps = (state, props) => ({ + antenna: state.getIn(['antennas', props.params.id]), + hasUnread: state.getIn(['timelines', `antenna:${props.params.id}`, 'unread']) > 0, +}); + +class AntennaTimeline extends PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + antenna: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]), + intl: PropTypes.object.isRequired, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('ANTENNA_TIMELINE', { id: this.props.params.id })); + this.context.router.history.push('/'); + } + }; + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + }; + + componentDidMount () { + const { dispatch } = this.props; + const { id } = this.props.params; + + dispatch(fetchAntenna(id)); + dispatch(expandAntennaTimeline(id)); + + this.disconnect = dispatch(connectAntennaStream(id)); + } + + UNSAFE_componentWillReceiveProps (nextProps) { + const { dispatch } = this.props; + const { id } = nextProps.params; + + if (id !== this.props.params.id) { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + + dispatch(fetchAntenna(id)); + dispatch(expandAntennaTimeline(id)); + + this.disconnect = dispatch(connectAntennaStream(id)); + } + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + }; + + handleLoadMore = maxId => { + const { id } = this.props.params; + this.props.dispatch(expandAntennaTimeline(id, { maxId })); + }; + + handleEditClick = () => { + this.context.router.history.push(`/antennasw/${this.props.params.id}`); + }; + + handleDeleteClick = () => { + const { dispatch, columnId, intl } = this.props; + const { id } = this.props.params; + + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => { + dispatch(deleteAntenna(id)); + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + this.context.router.history.push('/antennasw'); + } + }, + }, + })); + }; + + render () { + const { hasUnread, columnId, multiColumn, antenna } = this.props; + const { id } = this.props.params; + const pinned = !!columnId; + const title = antenna ? antenna.get('title') : id; + + if (typeof antenna === 'undefined') { + return ( + +
+ +
+
+ ); + } else if (antenna === false) { + return ( + + ); + } + + return ( + + +
+ + + +
+
+ + } + bindToDocument={!multiColumn} + /> + + + {title} + + +
+ ); + } + +} + +export default connect(mapStateToProps)(injectIntl(AntennaTimeline)); diff --git a/app/javascript/mastodon/features/antennas/index.jsx b/app/javascript/mastodon/features/antennas/index.jsx index bcc8f7e3e1..1d7fe71a93 100644 --- a/app/javascript/mastodon/features/antennas/index.jsx +++ b/app/javascript/mastodon/features/antennas/index.jsx @@ -77,7 +77,7 @@ class Antennas extends ImmutablePureComponent { bindToDocument={!multiColumn} > {antennas.map(antenna => ( - +

{antenna.get('accounts_count')} {antenna.get('domains_count')} diff --git a/app/javascript/mastodon/features/list_timeline/index.jsx b/app/javascript/mastodon/features/list_timeline/index.jsx index 768acd983b..b46a8d11c9 100644 --- a/app/javascript/mastodon/features/list_timeline/index.jsx +++ b/app/javascript/mastodon/features/list_timeline/index.jsx @@ -146,7 +146,7 @@ class ListTimeline extends PureComponent { handleEditAntennaClick = (e) => { const id = e.currentTarget.getAttribute('data-id'); - window.open(`/antennas/${id}/edit`, '_blank'); + this.context.router.history.push(`/antennasw/${id}/edit`); } handleRepliesPolicyChange = ({ target }) => { diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 39ae5956c4..fc9f28b425 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -48,6 +48,7 @@ import { StatusReferences, DirectTimeline, HashtagTimeline, + AntennaTimeline, Notifications, FollowRequests, FavouritedStatuses, @@ -210,6 +211,7 @@ class SwitchingColumnsArea extends PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 85e612a541..9fcfe73ed9 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -34,6 +34,10 @@ export function DirectTimeline() { return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline'); } +export function AntennaTimeline () { + return import(/* webpackChunkName: "features/antenna_timeline" */'../../antenna_timeline'); +} + export function ListTimeline () { return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline'); } diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 7c06f3a6bc..7cf98f3e36 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -90,6 +90,14 @@ class FeedManager true end + def push_to_antenna(antenna, status, update: false) + return false unless add_to_feed(:antenna, antenna.id, status, aggregate_reblogs: antenna.account.user&.aggregates_reblogs?) + + trim(:antenna, antenna.id) + PushUpdateWorker.perform_async(antenna.account_id, status.id, "timeline:antenna:#{antenna.id}", { 'update' => update }) if push_update_required?("timeline:antenna:#{antenna.id}") + true + end + # Remove a status from a list feed and send a streaming API update # @param [List] list # @param [Status] status @@ -102,6 +110,13 @@ class FeedManager true end + def unpush_from_antenna(antenna, status, update: false) + return false unless remove_from_feed(:antenna, antenna.id, status, aggregate_reblogs: antenna.account.user&.aggregates_reblogs?) + + redis.publish("timeline:antenna:#{antenna.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update + true + end + # Fill a home feed with an account's statuses # @param [Account] from_account # @param [Account] into_account diff --git a/app/models/antenna_feed.rb b/app/models/antenna_feed.rb new file mode 100644 index 0000000000..6bef09a4d9 --- /dev/null +++ b/app/models/antenna_feed.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AntennaFeed < Feed + def initialize(antenna) + super(:antenna, antenna.id) + end +end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index a54d9e96ec..2d67276379 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -51,7 +51,7 @@ class FanOutOnWriteService < BaseService when :public, :unlisted, :public_unlisted, :login, :private deliver_to_all_followers! deliver_to_lists! - deliver_to_antennas! if [:public, :public_unlisted, :login].include?(@status.visibility.to_sym) && !@account.dissubscribable + deliver_to_antennas! unless @account.dissubscribable deliver_to_stl_antennas! when :limited deliver_to_lists_mentioned_accounts_only! @@ -159,9 +159,6 @@ class FanOutOnWriteService < BaseService antennas = Antenna.availables antennas = antennas.left_joins(:antenna_domains).where(any_domains: true).or(Antenna.left_joins(:antenna_domains).where(antenna_domains: { name: domain })) - antennas = antennas.where(with_media_only: false) unless @status.with_media? - antennas = antennas.where(ignore_reblog: false) unless @status.reblog? - antennas = antennas.where(stl: false) antennas = Antenna.where(id: antennas.select(:id)) antennas = antennas.left_joins(:antenna_accounts).where(any_accounts: true).or(Antenna.left_joins(:antenna_accounts).where(antenna_accounts: { account: @account })) @@ -171,13 +168,17 @@ class FanOutOnWriteService < BaseService antennas = antennas.left_joins(:antenna_tags).where(any_tags: true).or(Antenna.left_joins(:antenna_tags).where(antenna_tags: { tag_id: tag_ids })) antennas = antennas.where(account_id: Account.without_suspended.joins(:user).select('accounts.id').where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)) + antennas = antennas.where(account: @status.account.followers) if [:public, :public_unlisted, :login].exclude?(@status.visibility.to_sym) + antennas = antennas.where(with_media_only: false) unless @status.with_media? + antennas = antennas.where(ignore_reblog: false) unless @status.reblog? + antennas = antennas.where(stl: false) collection = AntennaCollection.new(@status, @options[:update], false) antennas.in_batches do |ans| ans.each do |antenna| next unless antenna.enabled? - next if antenna.keywords.any? && antenna.keywords.none? { |keyword| @status.text.include?(keyword) } + next if antenna.keywords&.any? && antenna.keywords&.none? { |keyword| @status.text.include?(keyword) } next if antenna.exclude_keywords&.any? { |keyword| @status.text.include?(keyword) } next if antenna.exclude_accounts&.include?(@status.account_id) next if antenna.exclude_domains&.include?(domain) @@ -273,25 +274,25 @@ class FanOutOnWriteService < BaseService def push(antenna) if antenna.list_id.zero? - @home_account_ids << antenna.account_id - else - @list_ids << antenna.list_id + @home_account_ids << { id: antenna.account_id, antenna_id: antenna.id } if @home_account_ids.none? { |id| id.id == antenna.account_id } + elsif @list_ids.none? { |id| id.id == antenna.list_id } + @list_ids << { id: antenna.list_id, antenna_id: antenna.id } end end def deliver! - lists = @list_ids.uniq - homes = @home_account_ids.uniq + lists = @list_ids + homes = @home_account_ids if lists.any? FeedInsertWorker.push_bulk(lists) do |list| - [@status.id, list, 'list', { 'update' => @update, 'stl_home' => @stl_home || false }] + [@status.id, list[:id], 'list', { 'update' => @update, 'stl_home' => @stl_home || false, 'antenna_id' => list[:antenna_id] }] end end if homes.any? FeedInsertWorker.push_bulk(homes) do |home| - [@status.id, home, 'home', { 'update' => @update }] + [@status.id, home[:id], 'home', { 'update' => @update, 'antenna_id' => home[:antenna_id] }] end end end diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index e3e644c24f..c1b7edc90d 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -61,6 +61,11 @@ class FeedInsertWorker when :list FeedManager.instance.push_to_list(@list, @status, update: update?) end + + return if @options[:antenna_id].blank? + + antenna = Antenna.find(@options[:antenna_id]) + FeedManager.instance.push_to_antenna(antenna, @status, update: update?) if antenna.present? end def perform_unpush @@ -70,6 +75,11 @@ class FeedInsertWorker when :list FeedManager.instance.unpush_from_list(@list, @status, update: true) end + + return if @options[:antenna_id].blank? + + antenna = Antenna.find(@options[:antenna_id]) + FeedManager.instance.unpush_from_antenna(antenna, @status, update: true) if antenna.present? end def perform_notify diff --git a/config/routes.rb b/config/routes.rb index 5042f7b165..0a801389e4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,7 @@ Rails.application.routes.draw do /conversations /lists/(*any) /antennasw/(*any) + /antennast/(*any) /notifications /favourites /emoji_reactions diff --git a/config/routes/api.rb b/config/routes/api.rb index d959838326..30a86f56c6 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -49,6 +49,7 @@ namespace :api, format: false do resource :public, only: :show, controller: :public resources :tag, only: :show resources :list, only: :show + resources :antenna, only: :show end get '/streaming', to: 'streaming#index' diff --git a/streaming/index.js b/streaming/index.js index cf08110803..7331e004ed 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -701,6 +701,33 @@ const startServer = async () => { }); }); + /** + * @param {string} antennaId + * @param {any} req + * @returns {Promise.} + */ + const authorizeAntennaAccess = (antennaId, req) => new Promise((resolve, reject) => { + const { accountId } = req; + + pgPool.connect((err, client, done) => { + if (err) { + reject(); + return; + } + + client.query('SELECT id, account_id FROM antennas WHERE id = $1 LIMIT 1', [antennaId], (err, result) => { + done(); + + if (err || result.rows.length === 0 || result.rows[0].account_id !== accountId) { + reject(); + return; + } + + resolve(); + }); + }); + }); + /** * @param {string[]} ids * @param {any} req @@ -1214,6 +1241,17 @@ const startServer = async () => { reject('Not authorized to stream this list'); }); + break; + case 'antenna': + authorizeAntennaAccess(params.antenna, req).then(() => { + resolve({ + channelIds: [`timeline:antenna:${params.antenna}`], + options: { needsFiltering: false }, + }); + }).catch(() => { + reject('Not authorized to stream this antenna'); + }); + break; default: reject('Unknown stream type'); @@ -1228,6 +1266,8 @@ const startServer = async () => { const streamNameFromChannelName = (channelName, params) => { if (channelName === 'list') { return [channelName, params.list]; + } else if (channelName === 'antenna') { + return [channelName, params.antenna]; } else if (['hashtag', 'hashtag:local'].includes(channelName)) { return [channelName, params.tag]; } else {