Fix status expires feature

This commit is contained in:
noellabo 2021-07-16 15:38:50 +09:00
parent 8ebcbaded3
commit 0b8c3df283
61 changed files with 589 additions and 140 deletions

View file

@ -18,7 +18,7 @@ class Api::V1::BookmarksController < Api::BaseController
end
def cached_bookmarks
cache_collection(Status.where(id: results.pluck(:status_id)), Status)
cache_collection(Status.include_expired.where(id: results.pluck(:status_id)), Status)
end
def results

View file

@ -18,7 +18,7 @@ class Api::V1::EmojiReactionsController < Api::BaseController
end
def cached_emoji_reactions
cache_collection(Status.where(id: results.pluck(:status_id)), Status)
cache_collection(Status.include_expired.where(id: results.pluck(:status_id)), Status)
end
def results

View file

@ -18,7 +18,7 @@ class Api::V1::FavouritesController < Api::BaseController
end
def cached_favourites
cache_collection(Status.where(id: results.pluck(:status_id)), Status)
cache_collection(Status.include_expired.where(id: results.pluck(:status_id)), Status)
end
def results

View file

@ -18,7 +18,7 @@ class Api::V1::Statuses::BookmarksController < Api::BaseController
if bookmark
@status = bookmark.status
else
@status = Status.find(params[:status_id])
@status = Status.include_expired.find(params[:status_id])
authorize @status, :show?
end

View file

@ -22,7 +22,7 @@ class Api::V1::Statuses::EmojiReactionsController < Api::BaseController
private
def set_status
@status = Status.find(params[:status_id])
@status = Status.include_expired.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found

View file

@ -19,7 +19,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
@status = fav.status
UnfavouriteWorker.perform_async(current_account.id, @status.id)
else
@status = Status.find(params[:status_id])
@status = Status.include_expired.find(params[:status_id])
authorize @status, :show?
end

View file

@ -8,19 +8,12 @@ class Api::V1::Statuses::PinsController < Api::BaseController
before_action :set_status
def create
StatusPin.create!(account: current_account, status: @status)
distribute_add_activity!
PinService.new.call(current_account, @status)
render json: @status, serializer: REST::StatusSerializer
end
def destroy
pin = StatusPin.find_by(account: current_account, status: @status)
if pin
pin.destroy!
distribute_remove_activity!
end
UnpinService.new.call(current_account, @status)
render json: @status, serializer: REST::StatusSerializer
end
@ -29,24 +22,4 @@ class Api::V1::Statuses::PinsController < Api::BaseController
def set_status
@status = Status.find(params[:status_id])
end
def distribute_add_activity!
json = ActiveModelSerializers::SerializableResource.new(
@status,
serializer: ActivityPub::AddSerializer,
adapter: ActivityPub::Adapter
).as_json
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id)
end
def distribute_remove_activity!
json = ActiveModelSerializers::SerializableResource.new(
@status,
serializer: ActivityPub::RemoveSerializer,
adapter: ActivityPub::Adapter
).as_json
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account.id)
end
end

View file

@ -9,6 +9,7 @@ class Api::V1::StatusesController < Api::BaseController
before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create]
before_action :set_circle, only: [:create]
before_action :set_expire, only: [:create]
override_rate_limit_headers :create, family: :statuses
@ -46,7 +47,7 @@ class Api::V1::StatusesController < Api::BaseController
spoiler_text: status_params[:spoiler_text],
visibility: status_params[:visibility],
scheduled_at: status_params[:scheduled_at],
expires_at: status_params[:expires_at],
expires_at: @expires_at,
expires_action: status_params[:expires_action],
application: doorkeeper_token.application,
poll: status_params[:poll],
@ -98,6 +99,10 @@ class Api::V1::StatusesController < Api::BaseController
render json: { error: I18n.t('statuses.errors.circle_not_found') }, status: 404
end
def set_expire
@expires_at = status_params[:expires_at] || status_params[:expires_in].blank? ? nil : status_params[:expires_in].to_i.seconds.from_now
end
def status_params
params.permit(
:status,
@ -108,6 +113,7 @@ class Api::V1::StatusesController < Api::BaseController
:visibility,
:scheduled_at,
:quote_id,
:expires_in,
:expires_at,
:expires_action,
media_ids: [],

View file

@ -4,6 +4,7 @@ import { connectStream } from '../stream';
import {
updateTimeline,
deleteFromTimelines,
expireFromTimelines,
expandHomeTimeline,
connectTimeline,
disconnectTimeline,
@ -80,6 +81,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
case 'expire':
dispatch(expireFromTimelines(data.payload));
break;
case 'notification':
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
break;

View file

@ -10,6 +10,7 @@ import { uniq } from '../utils/uniq';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
export const TIMELINE_EXPIRE = 'TIMELINE_EXPIRE';
export const TIMELINE_CLEAR = 'TIMELINE_CLEAR';
export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
@ -88,6 +89,22 @@ export function deleteFromTimelines(id) {
};
};
export function expireFromTimelines(id) {
return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id'));
const quotes = getState().get('statuses').filter(status => status.get('quote_id') === id).map(status => status.get('id'));
dispatch({
type: TIMELINE_EXPIRE,
id,
accountId,
references,
quotes,
});
};
};
export function clearTimeline(timeline) {
return (dispatch) => {
dispatch({ type: TIMELINE_CLEAR, timeline });

View file

@ -376,11 +376,11 @@ class StatusActionBar extends ImmutablePureComponent {
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' disabled={expired} title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate || expired} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate disabled={!me && expired} active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
<IconButton className='status__action-bar-button star-icon' animate disabled={!status.get('favourited') && expired} active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{show_quote_button && <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />}
{enableReaction && <div className='status__action-bar-dropdown'><ReactionPickerDropdown disabled={expired} active={status.get('emoji_reactioned')} pressed={status.get('emoji_reactioned')} iconButtonClass='status__action-bar-button' onPickEmoji={this.handleEmojiPick} onRemoveEmoji={this.handleEmojiRemove} /></div>}
{shareButton}
{show_bookmark_button && <IconButton className='status__action-bar-button bookmark-icon' disabled={!me && expired} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />}
{show_bookmark_button && <IconButton className='status__action-bar-button bookmark-icon' disabled={!status.get('bookmarked') && expired} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />}
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer

View file

@ -323,11 +323,11 @@ class ActionBar extends React.PureComponent {
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton disabled={expired} title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate || expired} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} disabled={!status.get('favourited') && expired} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{show_quote_button && <div className='detailed-status__button'><IconButton disabled={!publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>}
{enableReaction && <div className='detailed-status__button'><ReactionPickerDropdown disabled={expired} active={status.get('emoji_reactioned')} pressed={status.get('emoji_reactioned')} iconButtonClass='detailed-status__action-bar-button' onPickEmoji={this.handleEmojiPick} onRemoveEmoji={this.handleEmojiRemove} /></div>}
{shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} disabled={!status.get('bookmarked') && expired} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
<div className='detailed-status__action-bar-dropdown'>
<DropdownMenuContainer size={18} icon='ellipsis-h' status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />

View file

@ -46,7 +46,7 @@ import {
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
COMPOSE_CHANGE_MEDIA_FOCUS,
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import { TIMELINE_DELETE, TIMELINE_EXPIRE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store';
import { REDRAFT } from '../actions/statuses';
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
@ -476,6 +476,7 @@ export default function compose(state = initialState, action) {
case COMPOSE_TAG_HISTORY_UPDATE:
return state.set('tagHistory', fromJS(action.tags));
case TIMELINE_DELETE:
case TIMELINE_EXPIRE:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);
} else {

View file

@ -3,7 +3,7 @@ import {
ACCOUNT_MUTE_SUCCESS,
} from '../actions/accounts';
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines';
import { TIMELINE_DELETE, TIMELINE_EXPIRE, TIMELINE_UPDATE } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from '../compare_id';
@ -97,6 +97,7 @@ export default function replies(state = initialState, action) {
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, action.ancestors, action.descendants);
case TIMELINE_DELETE:
case TIMELINE_EXPIRE:
return deleteFromContexts(state, [action.id]);
case TIMELINE_UPDATE:
return updateContext(state, action.status);

View file

@ -1,5 +1,5 @@
import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
import { TIMELINE_DELETE } from '../actions/timelines';
import { TIMELINE_DELETE, TIMELINE_EXPIRE } from '../actions/timelines';
import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
@ -12,6 +12,7 @@ export default function modal(state = ImmutableStack(), action) {
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state;
case TIMELINE_DELETE:
case TIMELINE_EXPIRE:
return state.filterNot((modal) => modal.get('modalProps').statusId === action.id);
default:
return state;

View file

@ -27,7 +27,7 @@ import {
APP_UNFOCUS,
} from '../actions/app';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
import { TIMELINE_DELETE, TIMELINE_EXPIRE, TIMELINE_DISCONNECT } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from '../compare_id';

View file

@ -1,5 +1,5 @@
import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'mastodon/actions/picture_in_picture';
import { TIMELINE_DELETE } from '../actions/timelines';
import { TIMELINE_DELETE, TIMELINE_EXPIRE } from '../actions/timelines';
const initialState = {
statusId: null,
@ -18,6 +18,7 @@ export default function pictureInPicture(state = initialState, action) {
case PICTURE_IN_PICTURE_REMOVE:
return { ...initialState };
case TIMELINE_DELETE:
case TIMELINE_EXPIRE:
return (state.statusId === action.id) ? { ...initialState } : state;
default:
return state;

View file

@ -1,6 +1,7 @@
import {
TIMELINE_UPDATE,
TIMELINE_DELETE,
TIMELINE_EXPIRE,
TIMELINE_CLEAR,
TIMELINE_EXPAND_SUCCESS,
TIMELINE_EXPAND_REQUEST,
@ -107,6 +108,22 @@ const deleteStatus = (state, id, references, exclude_account = null) => {
return state;
};
const expireStatus = (state, id, references, exclude_account) => {
state.keySeq().forEach(timeline => {
if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) {
const helper = list => list.filterNot(item => item === id);
state = state.updateIn([timeline, 'items'], helper).updateIn([timeline, 'pendingItems'], helper);
}
});
// Remove reblogs of deleted status
references.forEach(ref => {
state = deleteStatus(state, ref, []);
});
return state;
};
const clearTimeline = (state, timeline) => {
return state.set(timeline, initialTimeline);
};
@ -153,6 +170,8 @@ export default function timelines(state = initialState, action) {
return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case TIMELINE_EXPIRE:
return expireStatus(state, action.id, action.references, action.accountId);
case TIMELINE_CLEAR:
return clearTimeline(state, action.timeline);
case ACCOUNT_BLOCK_SUCCESS:

View file

@ -28,6 +28,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
@params = {}
process_status_params
process_expiry_params
process_audience
ApplicationRecord.transaction do
@ -40,6 +41,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
end
distribute(@status)
expire_queue_action
end
def process_status_params
@ -50,11 +52,30 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
uri: @json['id'],
created_at: @json['published'],
override_timestamps: @options[:override_timestamps],
visibility: visibility_from_audience
visibility: visibility_from_audience,
expires_at: @json['expiry'],
expires_action: :mark,
}
end
end
def process_expiry_params
expiry = @object['expiry'].to_time rescue nil
if expiry.nil?
@params
elsif expiry <= Time.now.utc + PostStatusService::MIN_EXPIRE_OFFSET
@params.merge!({
expired_at: @object['expiry']
})
else
@params.merge!({
expires_at: @object['expiry'],
expires_action: :mark,
})
end
end
def attach_mentions(status)
@mentions.each do |mention|
mention.status = status
@ -62,6 +83,15 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
end
end
def expire_queue_action
@status.status_expire.queue_action if expires_soon?
end
def expires_soon?
expires_at = @status&.status_expire&.expires_at
expires_at.present? && expires_at <= Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET
end
def announceable?(status)
status.account_id == @account.id || (@account.group? && dereferenced?) || status.distributable? || status.account.mutual?(@account)
end

View file

@ -75,6 +75,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
process_quote
process_status_params
process_expiry_params
process_tags
process_audience
@ -88,6 +89,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
distribute(@status)
forward_for_conversation
forward_for_reply
expire_queue_action
end
def find_existing_status
@ -115,11 +117,27 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
media_attachment_ids: process_attachments.take(4).map(&:id),
poll: process_poll,
quote: quote,
expires_at: @object['expiry'],
}
end
end
def process_expiry_params
expiry = @object['expiry'].to_time rescue nil
if expiry.nil?
@params
elsif expiry <= Time.now.utc + PostStatusService::MIN_EXPIRE_OFFSET
@params.merge!({
expired_at: @object['expiry']
})
else
@params.merge!({
expires_at: @object['expiry'],
expires_action: :mark,
})
end
end
def attach_tags(status)
@tags.each do |tag|
status.tags << tag
@ -475,6 +493,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
end
def expire_queue_action
@status.status_expire.queue_action if expires_soon?
end
def expires_soon?
expires_at = @status&.status_expire&.expires_at
expires_at.present? && expires_at <= Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET
end
def increment_voters_count!
poll = replied_to_status.preloadable_poll

View file

@ -4,6 +4,8 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
def perform
if @account.uri == object_uri
delete_person
elsif @json['expiry'].present?
expire_note
else
delete_note
end
@ -32,13 +34,27 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
Tombstone.find_or_create_by(uri: object_uri, account: @account)
end
@status = Status.include_expired.find_by(uri: object_uri, account: @account)
@status ||= Status.include_expired.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
return if @status.nil?
forward! if @json['signature'].present? && @status.distributable?
delete_now!
end
end
def expire_note
return if object_uri.nil?
lock_or_return("delete_status_in_progress:#{object_uri}", 5.minutes.seconds) do
@status = Status.find_by(uri: object_uri, account: @account)
@status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
return if @status.nil?
forward! if @json['signature'].present? && @status.distributable?
delete_now!
expire_now!
end
end
@ -80,6 +96,10 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
RemoveStatusService.new.call(@status, redraft: false)
end
def expire_now!
RemoveStatusService.new.call(@status, redraft: false, mark_expired: true)
end
def payload
@payload ||= Oj.dump(@json)
end

View file

@ -32,7 +32,7 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
end
def try_undo_announce
status = Status.where.not(reblog_of_id: nil).find_by(uri: object_uri, account: @account)
status = Status.include_expired.where.not(reblog_of_id: nil).find_by(uri: object_uri, account: @account)
if status.present?
RemoveStatusService.new.call(status)
true
@ -79,8 +79,8 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
def undo_announce
return if object_uri.nil?
status = Status.find_by(uri: object_uri, account: @account)
status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
status = Status.include_expired.find_by(uri: object_uri, account: @account)
status ||= Status.include_expired.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
if status.nil?
delete_later!(object_uri)

View file

@ -66,10 +66,10 @@ class FeedManager
# @param [Account] account
# @param [Status] status
# @return [Boolean]
def unpush_from_home(account, status)
def unpush_from_home(account, status, **options)
return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
redis.publish("timeline:#{account.id}", Oj.dump(event: options[:mark_expired] ? :expire : :delete, payload: status.id.to_s))
true
end
@ -89,10 +89,10 @@ class FeedManager
# @param [List] list
# @param [Status] status
# @return [Boolean]
def unpush_from_list(list, status)
def unpush_from_list(list, status, **options)
return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
redis.publish("timeline:list:#{list.id}", Oj.dump(event: options[:mark_expired] ? :expire : :delete, payload: status.id.to_s))
true
end

View file

@ -3,10 +3,11 @@
class StatusFilter
attr_reader :status, :account
def initialize(status, account, preloaded_relations = {})
def initialize(status, account, preloaded_account_relations = {}, preloaded_status_relations = {})
@status = status
@account = account
@preloaded_relations = preloaded_relations
@preloaded_account_relations = preloaded_account_relations
@preloaded_status_relations = preloaded_status_relations
end
def filtered?
@ -25,15 +26,15 @@ class StatusFilter
end
def blocking_account?
@preloaded_relations[:blocking] ? @preloaded_relations[:blocking][status.account_id] : account.blocking?(status.account_id)
@preloaded_account_relations[:blocking] ? @preloaded_account_relations[:blocking][status.account_id] : account.blocking?(status.account_id)
end
def blocking_domain?
@preloaded_relations[:domain_blocking_by_domain] ? @preloaded_relations[:domain_blocking_by_domain][status.account_domain] : account.domain_blocking?(status.account_domain)
@preloaded_account_relations[:domain_blocking_by_domain] ? @preloaded_account_relations[:domain_blocking_by_domain][status.account_domain] : account.domain_blocking?(status.account_domain)
end
def muting_account?
@preloaded_relations[:muting] ? @preloaded_relations[:muting][status.account_id] : account.muting?(status.account_id)
@preloaded_account_relations[:muting] ? @preloaded_account_relations[:muting][status.account_id] : account.muting?(status.account_id)
end
def silenced_account?
@ -45,7 +46,7 @@ class StatusFilter
end
def account_following_status_account?
@preloaded_relations[:following] ? @preloaded_relations[:following][status.account_id] : account&.following?(status.account_id)
@preloaded_account_relations[:following] ? @preloaded_account_relations[:following][status.account_id] : account&.following?(status.account_id)
end
def blocked_by_policy?
@ -53,6 +54,6 @@ class StatusFilter
end
def policy_allows_show?
StatusPolicy.new(account, status, @preloaded_relations).show?
StatusPolicy.new(account, status, @preloaded_account_relations, @preloaded_status_relations).show?
end
end

View file

@ -16,7 +16,7 @@ class Bookmark < ApplicationRecord
update_index('statuses', :status) if Chewy.enabled?
belongs_to :account, inverse_of: :bookmarks
belongs_to :status, inverse_of: :bookmarks
belongs_to :status, -> { unscope(where: :expired_at) }, inverse_of: :bookmarks
validates :status_id, uniqueness: { scope: :account_id }

View file

@ -83,10 +83,10 @@ module StatusThreadingConcern
def find_statuses_from_tree_path(ids, account, promote: false)
statuses = Status.with_accounts(ids).to_a
account_ids = statuses.map(&:account_id).uniq
domains = statuses.filter_map(&:account_domain).uniq
relations = relations_map_for_account(account, account_ids, domains)
account_relations = relations_map_for_account(account, account_ids)
status_relations = relations_map_for_status(account, statuses)
statuses.reject! { |status| StatusFilter.new(status, account, relations).filtered? }
statuses.reject! { |status| StatusFilter.new(status, account, account_relations, status_relations).filtered? }
# Order ancestors/descendants by tree path
statuses.sort_by! { |status| ids.index(status.id) }
@ -114,7 +114,7 @@ module StatusThreadingConcern
arr
end
def relations_map_for_account(account, account_ids, domains)
def relations_map_for_account(account, account_ids)
return {} if account.nil?
presenter = AccountRelationshipsPresenter.new(account_ids, account)
@ -127,4 +127,18 @@ module StatusThreadingConcern
domain_blocking_by_domain: presenter.domain_blocking,
}
end
def relations_map_for_status(account, statuses)
return {} if account.nil?
presenter = StatusRelationshipsPresenter.new(statuses, account)
{
reblogs_map: presenter.reblogs_map,
favourites_map: presenter.favourites_map,
bookmarks_map: presenter.bookmarks_map,
emoji_reactions_map: presenter.emoji_reactions_map,
mutes_map: presenter.mutes_map,
pins_map: presenter.pins_map,
}
end
end

View file

@ -21,7 +21,7 @@ class EmojiReaction < ApplicationRecord
after_commit :refresh_status
belongs_to :account
belongs_to :status, inverse_of: :emoji_reactions
belongs_to :status, -> { unscope(where: :expired_at) }, inverse_of: :emoji_reactions
belongs_to :custom_emoji, optional: true
has_one :notification, as: :activity, dependent: :destroy

View file

@ -16,7 +16,7 @@ class Favourite < ApplicationRecord
update_index('statuses', :status)
belongs_to :account, inverse_of: :favourites
belongs_to :status, inverse_of: :favourites
belongs_to :status, -> { unscope(where: :expired_at) }, inverse_of: :favourites
has_one :notification, as: :activity, dependent: :destroy

View file

@ -46,11 +46,11 @@ class Status < ApplicationRecord
attr_accessor :override_timestamps
attr_accessor :circle
attr_accessor :expires_at, :expires_action
update_index('statuses', :proper)
enum visibility: [:public, :unlisted, :private, :direct, :limited, :mutual], _suffix: :visibility
enum expires_action: [:delete, :hint], _prefix: :expires
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
@ -82,6 +82,7 @@ class Status < ApplicationRecord
has_one :notification, as: :activity, dependent: :destroy
has_one :status_stat, inverse_of: :status
has_one :poll, inverse_of: :status, dependent: :destroy
has_one :status_expire, inverse_of: :status
validates :uri, uniqueness: true, presence: true, unless: :local?
validates :text, presence: true, unless: -> { with_media? || reblog? }
@ -100,7 +101,7 @@ class Status < ApplicationRecord
scope :remote, -> { where(local: false).where.not(uri: nil) }
scope :local, -> { where(local: true).or(where(uri: nil)) }
scope :not_expired, -> { where("statuses.expires_at >= CURRENT_TIMESTAMP") }
scope :not_expired, -> { where(expired_at: nil) }
scope :include_expired, -> { unscoped.recent.kept }
scope :with_accounts, ->(ids) { where(id: ids).includes(:account) }
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
@ -129,6 +130,7 @@ class Status < ApplicationRecord
:media_attachments,
:conversation,
:status_stat,
:status_expire,
:tags,
:preview_cards,
:preloadable_poll,
@ -141,6 +143,7 @@ class Status < ApplicationRecord
:media_attachments,
:conversation,
:status_stat,
:status_expire,
:preloadable_poll,
account: [:account_stat, :user],
active_mentions: { account: :account_stat },
@ -151,10 +154,6 @@ class Status < ApplicationRecord
REAL_TIME_WINDOW = 6.hours
def expires_at=(val)
super(val.nil? ? 'infinity' : val)
end
def searchable_by(preloaded = nil)
ids = []
@ -255,8 +254,16 @@ class Status < ApplicationRecord
media_attachments.any?
end
def expired?
!expired_at.nil?
end
def expires?
expires_at != ::Float::INFINITY
status_expire.present?
end
def expiry
expires? && status_expire&.expires_mark? && status_expire&.expires_at || expired_at
end
def non_sensitive_with_media?
@ -326,6 +333,8 @@ class Status < ApplicationRecord
after_create_commit :store_uri, if: :local?
after_create_commit :update_statistics, if: :local?
after_create_commit :set_status_expire, if: -> { expires_at.present? }
after_update :update_status_expire, if: -> { expires_at.present? }
around_create Mastodon::Snowflake::Callbacks
@ -431,6 +440,14 @@ class Status < ApplicationRecord
private
def set_status_expire
create_status_expire(expires_at: expires_at, action: expires_action)
end
def update_status_expire
status_expire&.update(expires_at: expires_at, action: expires_action) || set_status_expire
end
def update_status_stat!(attrs)
return if marked_for_destruction? || destroyed?

View file

@ -0,0 +1,28 @@
# == Schema Information
#
# Table name: status_expires
#
# id :bigint(8) not null, primary key
# status_id :bigint(8) not null
# expires_at :datetime not null
# action :integer default("delete"), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class StatusExpire < ApplicationRecord
enum action: [:delete, :mark], _prefix: :expires
belongs_to :status
after_commit :reset_parent_cache
def queue_action
ExpiredStatusWorker.perform_at(expires_at, status_id)
end
private
def reset_parent_cache
Rails.cache.delete("statuses/#{status_id}")
end
end

View file

@ -2,7 +2,7 @@
class TimeLimit
TIME_LIMIT_RE = /^exp(?<value>\d+)(?<unit>[mhd])$/
VALID_DURATION = (1.minute..7.days)
VALID_DURATION = (1.minute..430.days)
def self.from_tags(tags, created_at = Time.now.utc)
return unless tags

View file

@ -1,13 +1,14 @@
# frozen_string_literal: true
class StatusPolicy < ApplicationPolicy
def initialize(current_account, record, preloaded_relations = {})
def initialize(current_account, record, preloaded_account_relations = {}, preloaded_status_relations = {})
super(current_account, record)
@preloaded_relations = preloaded_relations
@preloaded_account_relations = preloaded_account_relations
@preloaded_status_relations = preloaded_status_relations
end
delegate :reply?, to: :record
delegate :reply?, :expired?, to: :record
def index?
staff?
@ -15,6 +16,7 @@ class StatusPolicy < ApplicationPolicy
def show?
return false if author.suspended?
return false unless expired_show?
if requires_mention?
owned? || mention_exists?
@ -25,6 +27,10 @@ class StatusPolicy < ApplicationPolicy
end
end
def expired_show?
!expired? || owned? || favourited_status? || bookmarked_status? || emoji_reactioned_status?
end
def reblog?
!requires_mention? && (!private? || owned?) && show? && !blocking_author?
end
@ -84,19 +90,37 @@ class StatusPolicy < ApplicationPolicy
def blocking_author?
return false if current_account.nil?
@preloaded_relations[:blocking] ? @preloaded_relations[:blocking][author.id] : current_account.blocking?(author)
@preloaded_account_relations[:blocking] ? @preloaded_account_relations[:blocking][author.id] : current_account.blocking?(author)
end
def author_blocking?
return false if current_account.nil?
@preloaded_relations[:blocked_by] ? @preloaded_relations[:blocked_by][author.id] : author.blocking?(current_account)
@preloaded_account_relations[:blocked_by] ? @preloaded_account_relations[:blocked_by][author.id] : author.blocking?(current_account)
end
def following_author?
return false if current_account.nil?
@preloaded_relations[:following] ? @preloaded_relations[:following][author.id] : current_account.following?(author)
@preloaded_account_relations[:following] ? @preloaded_account_relations[:following][author.id] : current_account.following?(author)
end
def favourited_status?
return false if current_account.nil?
@preloaded_status_relations[:favourites_map] ? @preloaded_status_relations[:favourites_map][record.id] : current_account.favourited?(record)
end
def bookmarked_status?
return false if current_account.nil?
@preloaded_status_relations[:bookmarks_map] ? @preloaded_status_relations[:bookmarks_map][record.id] : current_account.bookmarked?(record)
end
def emoji_reactioned_status?
return false if current_account.nil?
@preloaded_status_relations[:emoji_reactions_map] ? @preloaded_status_relations[:emoji_reactions_map][record.id] : current_account.emoji_reactioned?(record)
end
def author

View file

@ -6,7 +6,7 @@ class AccountRelationshipsPresenter
:endorsed, :account_note
def initialize(account_ids, current_account_id, **options)
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i }
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i }.uniq
@current_account_id = current_account_id
@following = cached[:following]

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
attributes :id, :type, :actor, :published, :to, :cc, :virtual_object
attributes :id, :type, :actor, :published, :expiry, :to, :cc, :virtual_object
class << self
def from_status(status, use_bearcap: true)
@ -12,6 +12,7 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
presenter.published = status.created_at
presenter.to = ActivityPub::TagManager.instance.to(status)
presenter.cc = ActivityPub::TagManager.instance.cc(status)
presenter.expiry = status.expiry
presenter.virtual_object = begin
if status.reblog?

View file

@ -13,6 +13,7 @@ class StatusRelationshipsPresenter
@mutes_map = {}
@pins_map = {}
else
statuses = Status.where(id: statuses) if statuses.first.is_a?(Integer)
statuses = statuses.compact
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
conversation_ids = statuses.filter_map(&:conversation_id).uniq

View file

@ -13,10 +13,15 @@ class ActivityPub::ActivitySerializer < ActivityPub::Serializer
end
attributes :id, :type, :actor, :published, :to, :cc
attribute :expiry, if: -> { object.expiry.present? }
has_one :virtual_object, key: :object
def published
object.published.iso8601
end
def expiry
object.expiry.iso8601
end
end

View file

@ -20,6 +20,7 @@ class ActivityPub::DeleteSerializer < ActivityPub::Serializer
end
attributes :id, :type, :actor, :to
attribute :expiry, if: -> { expiry? }
has_one :object, serializer: TombstoneSerializer
@ -38,4 +39,12 @@ class ActivityPub::DeleteSerializer < ActivityPub::Serializer
def to
[ActivityPub::TagManager::COLLECTIONS[:public]]
end
def expiry?
instance_options && instance_options[:expiry].present?
end
def expiry
instance_options[:expiry].iso8601
end
end

View file

@ -15,7 +15,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
attribute :content
attribute :content_map, if: :language?
attribute :expiry, if: -> { object.expires? }
attribute :expiry, if: :has_expiry?
has_many :media_attachments, key: :attachment
has_many :virtual_tags, key: :tag
@ -84,8 +84,12 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
object.created_at.iso8601
end
def has_expiry?
object.expiry.present?
end
def expiry
object.expires_at.iso8601
object.expiry.iso8601
end
def url

View file

@ -2,6 +2,7 @@
class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :to
attribute :expiry, if: -> { expiry? }
has_one :virtual_object, key: :object, serializer: ActivityPub::ActivitySerializer
@ -24,4 +25,12 @@ class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer
def virtual_object
ActivityPub::ActivityPresenter.from_status(object)
end
def expiry?
instance_options && instance_options[:expiry].present?
end
def expiry
instance_options[:expiry].expiry.iso8601
end
end

View file

@ -61,6 +61,9 @@ class REST::InstanceSerializer < ActiveModel::Serializer
max_characters: StatusLengthValidator::MAX_CHARS,
max_media_attachments: 4,
characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS,
min_expiration: TimeLimit::VALID_DURATION.min,
max_expiration: TimeLimit::VALID_DURATION.max,
supported_expires_actions: StatusExpire::actions.keys,
},
media_attachments: {

View file

@ -19,7 +19,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attribute :quote_id, if: :quote?
attribute :expires_at, if: :expires?
attribute :expires_at, if: :has_expires?
attribute :visibility_ex, if: :visibility_ex?
belongs_to :reblog, serializer: REST::StatusSerializer
@ -66,8 +66,12 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.quote?
end
def expires?
object.expires?
def has_expires?
object.expires? || object.expired?
end
def expires_at
object&.status_expire&.expires_at || object.expired_at
end
def visibility_ex?

View file

@ -62,15 +62,21 @@ class BatchedRemoveStatusService < BaseService
private
def unpush_from_home_timelines(account, statuses)
account.followers_for_local_distribution.includes(:user).reorder(nil).find_each do |follower|
Account.where(id: Account
.union(account.followers_for_local_distribution.reorder(nil).select(:id))
.union(account.subscribers_for_local_distribution.reorder(nil).select('account_subscribes.account_id as id'))
).includes(:user).find_each do |follower_and_subscriber|
statuses.each do |status|
FeedManager.instance.unpush_from_home(follower, status)
FeedManager.instance.unpush_from_home(follower_and_subscriber, status)
end
end
end
def unpush_from_list_timelines(account, statuses)
account.lists_for_local_distribution.select(:id, :account_id).includes(account: :user).reorder(nil).find_each do |list|
List.where(id: List
.union(account.lists_for_local_distribution.reorder(nil).select(:id))
.union(account.list_subscribers_for_local_distribution.reorder(nil).select('list_id as id'))
).includes(account: :user).find_each do |list|
statuses.each do |status|
FeedManager.instance.unpush_from_list(list, status)
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
class PinService < BaseService
def call(account, status)
@account = account
@status = status
return unless @account == @status.account
StatusPin.create!(account: @account, status: @status)
distribute_add_activity! if @account.local?
end
private
def distribute_add_activity!
json = ActiveModelSerializers::SerializableResource.new(
@status,
serializer: ActivityPub::AddSerializer,
adapter: ActivityPub::Adapter
).as_json
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), @account.id)
end
end

View file

@ -4,6 +4,7 @@ class PostStatusService < BaseService
include Redisable
MIN_SCHEDULE_OFFSET = 5.minutes.freeze
MIN_EXPIRE_OFFSET = 40.seconds.freeze # The original intention is 60 seconds, but we have a margin of 20 seconds.
# Post a text status update, fetch and notify remote users mentioned
# @param [Account] account Account from which to post
@ -35,6 +36,7 @@ class PostStatusService < BaseService
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
validate_media!
validate_expires!
preprocess_attributes!
preprocess_quote!
@ -75,8 +77,6 @@ class PostStatusService < BaseService
@visibility = :limited if @visibility&.to_sym != :direct && @in_reply_to&.limited_visibility?
@scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past?
@expires_at = @options[:expires_at]&.to_datetime
@expires_action = @options[:expires_action]
if @quote_id.nil? && md = @text.match(/QT:\s*\[\s*(https:\/\/.+?)\s*\]/)
@quote_id = quote_from_url(md[1])&.id
@text.sub!(/QT:\s*\[.*?\]/, '')
@ -127,7 +127,12 @@ class PostStatusService < BaseService
DistributionWorker.perform_async(@status.id)
ActivityPub::DistributionWorker.perform_async(@status.id)
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
DeleteExpiredStatusWorker.perform_at(@status.expires_at, @status.id) if @status.expires? && @status.expires_delete?
@status.status_expire.queue_action if expires_soon?
end
def expires_soon?
expires_at = @status&.status_expire&.expires_at
expires_at.present? && expires_at <= Time.now.utc + MIN_SCHEDULE_OFFSET
end
def validate_media!
@ -141,6 +146,26 @@ class PostStatusService < BaseService
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.not_ready') if @media.any?(&:not_processed?)
end
def validate_expires!
return if @options[:expires_at].blank?
@expires_at = @options[:expires_at].is_a?(Time) ? @options[:expires_at] : @options[:expires_at].to_time rescue nil
raise Mastodon::ValidationError, I18n.t('status_expire.validations.invalid_expire_at') if @expires_at.nil?
raise Mastodon::ValidationError, I18n.t('status_expire.validations.expire_in_the_past') if @expires_at <= Time.now.utc + MIN_EXPIRE_OFFSET
@expires_action = begin
case @options[:expires_action]&.to_sym
when :hint, :mark, nil
:mark
when :delete
:delete
else
raise Mastodon::ValidationError, I18n.t('status_expire.validations.invalid_expire_action')
end
end
end
def language_from_option(str)
ISO_639.find(str)&.alpha2
end

View file

@ -8,12 +8,14 @@ class ProcessHashtagsService < BaseService
Tag.find_or_create_by_names(tags) do |tag|
status.tags << tag
records << tag
tag.use!(status.account, status: status, at_time: status.created_at) if status.public_visibility?
tag.use!(status.account, status: status, at_time: status.created_at) if status.public_visibility? && !tag.name.match(TimeLimit::TIME_LIMIT_RE)
end
if status.local?
time_limit = TimeLimit.from_status(status)
if (time_limit.present?)
status.update(expires_at: time_limit.to_datetime, expires_action: :delete)
status.update(expires_at: time_limit.to_datetime, expires_action: :mark)
end
end
return unless status.distributable?

View file

@ -10,19 +10,25 @@ class RemoveStatusService < BaseService
# @option [Boolean] :redraft
# @option [Boolean] :immediate
# @option [Boolean] :original_removed
# @option [Boolean] :mark_expired
def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
@status = status
@account = status.account
@options = options
@status_expire = status.status_expire
@payload = Oj.dump(event: mark_expired? ? :expire : :delete, payload: status.id.to_s)
@status.discard
return if mark_expired? && @status_expire.nil?
@status.discard unless mark_expired?
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
remove_from_self if @account.local?
remove_from_followers
remove_from_lists
remove_from_subscribers
remove_from_subscribers_lists
# There is no reason to send out Undo activities when the
# cause is that the original object has been removed, since
@ -41,10 +47,17 @@ class RemoveStatusService < BaseService
remove_from_group if status.account.group?
remove_from_public
remove_from_media if @status.media_attachments.any?
remove_media
remove_media unless mark_expired?
end
if mark_expired?
UnpinService.new.call(@account, @status)
@status.update!(expired_at: @status_expire.expires_at)
@status_expire.destroy
else
@status_expire&.destroy
@status.destroy! if @options[:immediate] || !@status.reported?
end
else
raise Mastodon::RaceConditionError
end
@ -53,19 +66,35 @@ class RemoveStatusService < BaseService
private
def mark_expired?
@options[:mark_expired]
end
def remove_from_self
FeedManager.instance.unpush_from_home(@account, @status)
FeedManager.instance.unpush_from_home(@account, @status, @options)
end
def remove_from_followers
@account.followers_for_local_distribution.reorder(nil).find_each do |follower|
FeedManager.instance.unpush_from_home(follower, @status)
@account.followers_for_local_distribution.includes(:user).reorder(nil).find_each do |follower|
FeedManager.instance.unpush_from_home(follower, @status, @options)
end
end
def remove_from_lists
@account.lists_for_local_distribution.select(:id, :account_id).reorder(nil).find_each do |list|
FeedManager.instance.unpush_from_list(list, @status)
@account.lists_for_local_distribution.select(:id, :account_id).includes(account: :user).reorder(nil).find_each do |list|
FeedManager.instance.unpush_from_list(list, @status, @options)
end
end
def remove_from_subscribers
@account.subscribers_for_local_distribution.with_reblog(@status.reblog?).with_media(@status.proper).includes(account: :user).reorder(nil).find_each do |subscribing|
FeedManager.instance.unpush_from_home(subscribing.account, @status, @options)
end
end
def remove_from_subscribers_lists
@account.list_subscribers_for_local_distribution.with_reblog(@status.reblog?).with_media(@status.proper).includes(account: :user).reorder(nil).find_each do |subscribing|
FeedManager.instance.unpush_from_list(subscribing.list, @status, @options)
end
end
@ -95,7 +124,7 @@ class RemoveStatusService < BaseService
end
def signed_activity_json
@signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account))
@signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account, expiry: mark_expired? ? @status.expiry : nil))
end
def remove_reblogs

View file

@ -50,9 +50,10 @@ class SearchService < BaseService
results = definition.limit(@limit).offset(@offset).objects.compact
account_ids = results.map(&:account_id)
preloaded_relations = relations_map_for_account(@account, account_ids)
account_relations = relations_map_for_account(@account, account_ids)
status_relations = relations_map_for_status(@account, results)
results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
results.reject { |status| StatusFilter.new(status, @account, account_relations, status_relations).filtered? }
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
[]
end
@ -123,6 +124,18 @@ class SearchService < BaseService
}
end
def relations_map_for_status(account, statuses)
presenter = StatusRelationshipsPresenter.new(statuses, account)
{
reblogs_map: presenter.reblogs_map,
favourites_map: presenter.favourites_map,
bookmarks_map: presenter.bookmarks_map,
emoji_reactions_map: presenter.emoji_reactions_map,
mutes_map: presenter.mutes_map,
pins_map: presenter.pins_map,
}
end
def parsed_query
SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query))
end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
class UnpinService < BaseService
def call(account, status)
@account = account
@status = status
return unless @account == @status.account
pin = StatusPin.find_by(account: @account, status: @status)
if pin
pin.destroy!
distribute_remove_activity! if @account.local?
end
end
private
def distribute_remove_activity!
json = ActiveModelSerializers::SerializableResource.new(
@status,
serializer: ActivityPub::RemoveSerializer,
adapter: ActivityPub::Adapter
).as_json
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), @account.id)
end
end

View file

@ -1,14 +0,0 @@
# frozen_string_literal: true
class DeleteExpiredStatusWorker
include Sidekiq::Worker
sidekiq_options retry: 0, dead: false
def perform(status_id)
@status = Status.include_expired.find(status_id)
RemoveStatusService.new.call(@status, redraft: false)
rescue ActiveRecord::RecordNotFound
true
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class ExpiredStatusWorker
include Sidekiq::Worker
sidekiq_options retry: 0, dead: false
def perform(status_id)
status = Status.find(status_id)
status_expire = status.status_expire
RemoveStatusService.new.call(status, redraft: false, mark_expired: status_expire.present? && status_expire.expires_mark?)
rescue ActiveRecord::RecordNotFound
true
end
end

View file

@ -4,7 +4,7 @@ class RemovalWorker
include Sidekiq::Worker
def perform(status_id, options = {})
RemoveStatusService.new.call(Status.with_discarded.find(status_id), **options.symbolize_keys)
RemoveStatusService.new.call(Status.include_expired.with_discarded.find(status_id), **options.symbolize_keys)
rescue ActiveRecord::RecordNotFound
true
end

View file

@ -9,6 +9,7 @@ class Scheduler::ScheduledStatusesScheduler
publish_scheduled_statuses!
publish_scheduled_announcements!
unpublish_expired_announcements!
process_expired_statuses!
end
private
@ -40,4 +41,12 @@ class Scheduler::ScheduledStatusesScheduler
def expired_announcements
Announcement.published.where('ends_at IS NOT NULL AND ends_at <= ?', Time.now.utc)
end
def process_expired_statuses!
due_status_expires.find_each(&:queue_action)
end
def due_status_expires
StatusExpire.where('expires_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET)
end
end

View file

@ -1436,6 +1436,11 @@ en:
min_favs_hint: Doesn't delete any of your posts that has received more than this amount of favourites. Leave blank to delete posts regardless of their number of favourites
min_reblogs: Keep posts boosted more than
min_reblogs_hint: Doesn't delete any of your posts that has been boosted more than this number of times. Leave blank to delete posts regardless of their number of boosts
status_expire:
validations:
expire_in_the_past: It is already past expires. You cannot specify expires before the posting time.
invalid_expire_at: Invalid expires are specified.
invalid_expire_action: Invalid expires_action are specified.
stream_entries:
pinned: Pinned post
reblogged: boosted

View file

@ -1339,6 +1339,11 @@ ja:
public_long: 誰でも見ることができ、かつ公開タイムラインに表示されます
unlisted: 未収載
unlisted_long: 誰でも見ることができますが、公開タイムラインには表示されません
status_expire:
validations:
expire_in_the_past: 既に公開期限を過ぎています。投稿時間より前の公開期限は指定できません
invalid_expire_at: 不正な公開期限が指定されています
invalid_expire_action: 不正な公開期限時のアクションが指定されています
stream_entries:
pinned: 固定された投稿
reblogged: さんがブースト

View file

@ -0,0 +1,11 @@
class CreateStatusExpires < ActiveRecord::Migration[6.1]
def change
create_table :status_expires do |t|
t.references :status, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true }
t.datetime :expires_at, null: false, index: true
t.integer :action, default: 0, null: false
t.timestamps
end
end
end

View file

@ -0,0 +1,17 @@
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class AddExpiredAtToStatus < ActiveRecord::Migration[6.1]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def up
safety_assured { add_column :statuses, :expired_at, :datetime, default: nil, allow_null: true }
safety_assured { add_index :statuses, :expired_at, algorithm: :concurrently, name: :index_statuses_on_expired_at }
end
def down
remove_index :statuses, name: :index_statuses_on_expired_at
remove_column :statuses, :expired_at
end
end

View file

@ -0,0 +1,13 @@
class UpdateStatusesIndexExcludeExpired < ActiveRecord::Migration[6.1]
disable_ddl_transaction!
def up
safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], where: 'deleted_at IS NULL', order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20210710 }
remove_index :statuses, name: :index_statuses_20210627
end
def down
safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at, :expires_at], where: 'deleted_at IS NULL', order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20210627 }
remove_index :statuses, name: :index_statuses_20210710
end
end

View file

@ -0,0 +1,19 @@
class MigrateToNormalizeExpires < ActiveRecord::Migration[6.1]
def change
def up
safety_assured do
execute 'insert into status_expires (status_id, expires_at, action, created_at, updated_at) select id as status_id, expires_at, expires_action as action, created_at, updated_at from statuses where expires_at is not null and expires_at != \'infinity\';'
remove_column :statuses, :expires_at
remove_column :statuses, :expires_action
end
end
def down
safety_assured do
add_column :statuses, :expires_at, :datetime
add_column :statuses, :expires_action, :integer, default: 0, null: false
execute 'update statuses set expires_at = se.expires_at, expires_action = se.action from status_expires se;'
end
end
end
end

View file

@ -926,6 +926,16 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
t.index ["status_id"], name: "index_status_capability_tokens_on_status_id"
end
create_table "status_expires", force: :cascade do |t|
t.bigint "status_id", null: false
t.datetime "expires_at", null: false
t.integer "action", default: 0, null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["expires_at"], name: "index_status_expires_on_expires_at"
t.index ["status_id"], name: "index_status_expires_on_status_id", unique: true
end
create_table "status_pins", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "status_id", null: false
@ -964,11 +974,10 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
t.bigint "application_id"
t.bigint "in_reply_to_account_id"
t.bigint "poll_id"
t.bigint "quote_id"
t.datetime "deleted_at"
t.datetime "expires_at", default: ::Float::INFINITY, null: false
t.integer "expires_action", default: 0, null: false
t.index ["account_id", "id", "visibility", "updated_at", "expires_at"], name: "index_statuses_20210627", order: { id: :desc }, where: "(deleted_at IS NULL)"
t.bigint "quote_id"
t.datetime "expired_at"
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20210710", order: { id: :desc }, where: "((deleted_at IS NULL) AND (expired_at IS NULL))"
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
@ -1219,6 +1228,7 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade
add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade
add_foreign_key "status_capability_tokens", "statuses"
add_foreign_key "status_expires", "statuses", on_delete: :cascade
add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade
add_foreign_key "status_pins", "statuses", on_delete: :cascade
add_foreign_key "status_stats", "statuses", on_delete: :cascade

View file

@ -0,0 +1,4 @@
Fabricator(:status_expire) do
status_id nil
action 0
end

View file

@ -0,0 +1,4 @@
require 'rails_helper'
RSpec.describe StatusExpire, type: :model do
end