diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb index 9e5711c67..8bc80ac7c 100644 --- a/app/controllers/api/v1/bookmarks_controller.rb +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -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 diff --git a/app/controllers/api/v1/emoji_reactions_controller.rb b/app/controllers/api/v1/emoji_reactions_controller.rb index e93632c7f..db81ba4ec 100644 --- a/app/controllers/api/v1/emoji_reactions_controller.rb +++ b/app/controllers/api/v1/emoji_reactions_controller.rb @@ -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 diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index 52f37555d..7346f7ef3 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -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 diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb index b247d28c5..f9b7e33fa 100644 --- a/app/controllers/api/v1/statuses/bookmarks_controller.rb +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -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 diff --git a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb index 64fca6e40..ddb18f7e3 100644 --- a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb +++ b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb @@ -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 diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index 34e2efbf2..fecfedd28 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -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 diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb index 51b1621b6..a5d2841c2 100644 --- a/app/controllers/api/v1/statuses/pins_controller.rb +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -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 diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 9c6044c71..b987a16fb 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -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: [], diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 66a465a4e..1ae7912fa 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -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; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 895dddc88..1177cdda9 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -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 }); diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 9768cd6d9..a94ef6ded 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -376,11 +376,11 @@ class StatusActionBar extends ImmutablePureComponent {
- + {show_quote_button && } {enableReaction &&
} {shareButton} - {show_bookmark_button && } + {show_bookmark_button && }
-
+
{show_quote_button &&
} {enableReaction &&
} {shareButton} -
+
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index c8ba41189..ca4d9b01e 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -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 { diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js index 4c2d6cc8a..df20c032e 100644 --- a/app/javascript/mastodon/reducers/contexts.js +++ b/app/javascript/mastodon/reducers/contexts.js @@ -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); diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js index 41161a206..809bf769b 100644 --- a/app/javascript/mastodon/reducers/modal.js +++ b/app/javascript/mastodon/reducers/modal.js @@ -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; diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index c84f2b60c..7fe245229 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -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'; diff --git a/app/javascript/mastodon/reducers/picture_in_picture.js b/app/javascript/mastodon/reducers/picture_in_picture.js index 48772ae7f..90b588c21 100644 --- a/app/javascript/mastodon/reducers/picture_in_picture.js +++ b/app/javascript/mastodon/reducers/picture_in_picture.js @@ -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; diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index 0092e6797..d65b817a4 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -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: diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index ab4091f92..f7e803151 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -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 diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 76c524611..25390ef3f 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -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 diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 330c19e66..8ff2c2a5b 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -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 diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb index fbb07cd51..cdab9857b 100644 --- a/app/lib/activitypub/activity/undo.rb +++ b/app/lib/activitypub/activity/undo.rb @@ -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) diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 218e4f103..1f0057a86 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -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 diff --git a/app/lib/status_filter.rb b/app/lib/status_filter.rb index b6c80b801..cec8cbe5d 100644 --- a/app/lib/status_filter.rb +++ b/app/lib/status_filter.rb @@ -3,10 +3,11 @@ class StatusFilter attr_reader :status, :account - def initialize(status, account, preloaded_relations = {}) - @status = status - @account = account - @preloaded_relations = preloaded_relations + def initialize(status, account, preloaded_account_relations = {}, preloaded_status_relations = {}) + @status = status + @account = account + @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 diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb index 6334ef0df..b4155f7bf 100644 --- a/app/models/bookmark.rb +++ b/app/models/bookmark.rb @@ -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 } diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb index 72ff190bd..4f62f4c5f 100644 --- a/app/models/concerns/status_threading_concern.rb +++ b/app/models/concerns/status_threading_concern.rb @@ -81,12 +81,12 @@ module StatusThreadingConcern end 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) + statuses = Status.with_accounts(ids).to_a + account_ids = statuses.map(&:account_id).uniq + 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 diff --git a/app/models/emoji_reaction.rb b/app/models/emoji_reaction.rb index f867a1918..9e7204c5b 100644 --- a/app/models/emoji_reaction.rb +++ b/app/models/emoji_reaction.rb @@ -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 diff --git a/app/models/favourite.rb b/app/models/favourite.rb index 2f355739a..8e6f58954 100644 --- a/app/models/favourite.rb +++ b/app/models/favourite.rb @@ -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 diff --git a/app/models/status.rb b/app/models/status.rb index b06ed5649..375fc5f54 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -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? diff --git a/app/models/status_expire.rb b/app/models/status_expire.rb new file mode 100644 index 000000000..2b453537a --- /dev/null +++ b/app/models/status_expire.rb @@ -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 diff --git a/app/models/time_limit.rb b/app/models/time_limit.rb index b1a60fb8c..9296229dc 100644 --- a/app/models/time_limit.rb +++ b/app/models/time_limit.rb @@ -2,7 +2,7 @@ class TimeLimit TIME_LIMIT_RE = /^exp(?\d+)(?[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 diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 2bb079586..d342f8cd4 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -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 diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb index 314f95010..14ebe4e69 100644 --- a/app/presenters/account_relationships_presenter.rb +++ b/app/presenters/account_relationships_presenter.rb @@ -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] diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb index 1f7e8dc43..2b0764be3 100644 --- a/app/presenters/activitypub/activity_presenter.rb +++ b/app/presenters/activitypub/activity_presenter.rb @@ -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? diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 384fafffd..c2551727e 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -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 diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index 5bdf53f03..7a8a2154d 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -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 diff --git a/app/serializers/activitypub/delete_serializer.rb b/app/serializers/activitypub/delete_serializer.rb index a7d5bd469..d26cf4b57 100644 --- a/app/serializers/activitypub/delete_serializer.rb +++ b/app/serializers/activitypub/delete_serializer.rb @@ -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 diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 1558512b5..0f771dfd5 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -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 diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb index a925efc18..8f7fd3c81 100644 --- a/app/serializers/activitypub/undo_announce_serializer.rb +++ b/app/serializers/activitypub/undo_announce_serializer.rb @@ -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 diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index b258019af..336c5acab 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -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: { diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 58dd7e4e1..674848cf9 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -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? diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 328489765..77f1ee475 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -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 diff --git a/app/services/pin_service.rb b/app/services/pin_service.rb new file mode 100644 index 000000000..fdadca432 --- /dev/null +++ b/app/services/pin_service.rb @@ -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 + diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 983c85065..ac267592a 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -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 diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb index 7a3ee32b9..33a2df638 100644 --- a/app/services/process_hashtags_service.rb +++ b/app/services/process_hashtags_service.rb @@ -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 - time_limit = TimeLimit.from_status(status) - if (time_limit.present?) - status.update(expires_at: time_limit.to_datetime, expires_action: :delete) + if status.local? + time_limit = TimeLimit.from_status(status) + if (time_limit.present?) + status.update(expires_at: time_limit.to_datetime, expires_action: :mark) + end end return unless status.distributable? diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index dfa0c8688..679c03b4b 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -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 = 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 - @status.destroy! if @options[:immediate] || !@status.reported? + 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 diff --git a/app/services/search_service.rb b/app/services/search_service.rb index d05325d33..2d6f34c86 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -48,11 +48,12 @@ class SearchService < BaseService definition = definition.filter(range: { id: range }) end - results = definition.limit(@limit).offset(@offset).objects.compact - account_ids = results.map(&:account_id) - preloaded_relations = relations_map_for_account(@account, account_ids) + results = definition.limit(@limit).offset(@offset).objects.compact + account_ids = results.map(&:account_id) + 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 diff --git a/app/services/unpin_service.rb b/app/services/unpin_service.rb new file mode 100644 index 000000000..6ddeae546 --- /dev/null +++ b/app/services/unpin_service.rb @@ -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 diff --git a/app/workers/delete_expired_status_worker.rb b/app/workers/delete_expired_status_worker.rb deleted file mode 100644 index 1c442050d..000000000 --- a/app/workers/delete_expired_status_worker.rb +++ /dev/null @@ -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 diff --git a/app/workers/expired_status_worker.rb b/app/workers/expired_status_worker.rb new file mode 100644 index 000000000..9ef8eef27 --- /dev/null +++ b/app/workers/expired_status_worker.rb @@ -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 diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb index 2a1eaa89b..8ba4ef936 100644 --- a/app/workers/removal_worker.rb +++ b/app/workers/removal_worker.rb @@ -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 diff --git a/app/workers/scheduler/scheduled_statuses_scheduler.rb b/app/workers/scheduler/scheduled_statuses_scheduler.rb index 3bf6300b3..29ef8fe21 100644 --- a/app/workers/scheduler/scheduled_statuses_scheduler.rb +++ b/app/workers/scheduler/scheduled_statuses_scheduler.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index f864c5e4d..e92fe1364 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 914762496..8c60bef9a 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -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: さんがブースト diff --git a/db/migrate/20210706063140_create_status_expires.rb b/db/migrate/20210706063140_create_status_expires.rb new file mode 100644 index 000000000..e1b609d4d --- /dev/null +++ b/db/migrate/20210706063140_create_status_expires.rb @@ -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 diff --git a/db/migrate/20210708030726_add_expired_at_to_status.rb b/db/migrate/20210708030726_add_expired_at_to_status.rb new file mode 100644 index 000000000..58a3a947b --- /dev/null +++ b/db/migrate/20210708030726_add_expired_at_to_status.rb @@ -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 diff --git a/db/migrate/20210710035804_update_statuses_index_exclude_expired.rb b/db/migrate/20210710035804_update_statuses_index_exclude_expired.rb new file mode 100644 index 000000000..701c692e9 --- /dev/null +++ b/db/migrate/20210710035804_update_statuses_index_exclude_expired.rb @@ -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 diff --git a/db/post_migrate/20210706131532_migrate_to_normalize_expires.rb b/db/post_migrate/20210706131532_migrate_to_normalize_expires.rb new file mode 100644 index 000000000..ff66af954 --- /dev/null +++ b/db/post_migrate/20210706131532_migrate_to_normalize_expires.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index f1682b416..d9b517c99 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/spec/fabricators/status_expire_fabricator.rb b/spec/fabricators/status_expire_fabricator.rb new file mode 100644 index 000000000..966a2e368 --- /dev/null +++ b/spec/fabricators/status_expire_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:status_expire) do + status_id nil + action 0 +end \ No newline at end of file diff --git a/spec/models/status_expire_spec.rb b/spec/models/status_expire_spec.rb new file mode 100644 index 000000000..251e043f6 --- /dev/null +++ b/spec/models/status_expire_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe StatusExpire, type: :model do +end