Add antenna timeline

This commit is contained in:
KMY 2023-08-19 17:25:55 +09:00
parent f393aa2a85
commit 5c758b344c
15 changed files with 375 additions and 14 deletions

View file

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

View file

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

View file

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

View file

@ -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 (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (antenna === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={title}>
<ColumnHeader
icon='wifi'
active={hasUnread}
title={title}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings__row column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Icon id='pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
</button>
</div>
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`antenna_timeline-${columnId}`}
timelineId={`antenna:${id}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.antenna' defaultMessage='There is nothing in this antenna yet. When members of this list post new statuses, they will appear here.' />}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{title}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(AntennaTimeline));

View file

@ -77,7 +77,7 @@ class Antennas extends ImmutablePureComponent {
bindToDocument={!multiColumn}
>
{antennas.map(antenna => (
<ColumnLink key={antenna.get('id')} to={`/antennasw/${antenna.get('id')}`} icon='wifi' text={antenna.get('title')}>
<ColumnLink key={antenna.get('id')} to={`/antennast/${antenna.get('id')}`} icon='wifi' text={antenna.get('title')}>
<p className='antenna-list-detail'>
<span className='group'><Icon id='users' />{antenna.get('accounts_count')}</span>
<span className='group'><Icon id='sitemap' />{antenna.get('domains_count')}</span>

View file

@ -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 }) => {

View file

@ -48,6 +48,7 @@ import {
StatusReferences,
DirectTimeline,
HashtagTimeline,
AntennaTimeline,
Notifications,
FollowRequests,
FavouritedStatuses,
@ -210,6 +211,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/antennasw/:id' component={AntennaSetting} content={children} />
<WrappedRoute path='/antennast/:id' component={AntennaTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/emoji_reactions' component={EmojiReactedStatuses} content={children} />

View file

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

View file

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

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AntennaFeed < Feed
def initialize(antenna)
super(:antenna, antenna.id)
end
end

View file

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

View file

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

View file

@ -17,6 +17,7 @@ Rails.application.routes.draw do
/conversations
/lists/(*any)
/antennasw/(*any)
/antennast/(*any)
/notifications
/favourites
/emoji_reactions

View file

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

View file

@ -701,6 +701,33 @@ const startServer = async () => {
});
});
/**
* @param {string} antennaId
* @param {any} req
* @returns {Promise.<void>}
*/
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 {