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