Refactor feed manager (#14761)
This commit is contained in:
parent
169f9105ef
commit
65760f59df
@ -6,31 +6,54 @@ class FeedManager
|
|||||||
include Singleton
|
include Singleton
|
||||||
include Redisable
|
include Redisable
|
||||||
|
|
||||||
|
# Maximum number of items stored in a single feed
|
||||||
MAX_ITEMS = 400
|
MAX_ITEMS = 400
|
||||||
|
|
||||||
# Must be <= MAX_ITEMS or the tracking sets will grow forever
|
# Number of items in the feed since last reblog of status
|
||||||
|
# before the new reblog will be inserted. Must be <= MAX_ITEMS
|
||||||
|
# or the tracking sets will grow forever
|
||||||
REBLOG_FALLOFF = 40
|
REBLOG_FALLOFF = 40
|
||||||
|
|
||||||
|
# Execute block for every active account
|
||||||
|
# @yield [Account]
|
||||||
|
# @return [void]
|
||||||
def with_active_accounts(&block)
|
def with_active_accounts(&block)
|
||||||
Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each(&block)
|
Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each(&block)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Redis key of a feed
|
||||||
|
# @param [Symbol] type
|
||||||
|
# @param [Integer] id
|
||||||
|
# @param [Symbol] subtype
|
||||||
|
# @return [String]
|
||||||
def key(type, id, subtype = nil)
|
def key(type, id, subtype = nil)
|
||||||
return "feed:#{type}:#{id}" unless subtype
|
return "feed:#{type}:#{id}" unless subtype
|
||||||
|
|
||||||
"feed:#{type}:#{id}:#{subtype}"
|
"feed:#{type}:#{id}:#{subtype}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter?(timeline_type, status, receiver_id)
|
# Check if the status should not be added to a feed
|
||||||
if timeline_type == :home
|
# @param [Symbol] timeline_type
|
||||||
filter_from_home?(status, receiver_id, build_crutches(receiver_id, [status]))
|
# @param [Status] status
|
||||||
elsif timeline_type == :mentions
|
# @param [Account|List] receiver
|
||||||
filter_from_mentions?(status, receiver_id)
|
# @return [Boolean]
|
||||||
|
def filter?(timeline_type, status, receiver)
|
||||||
|
case timeline_type
|
||||||
|
when :home
|
||||||
|
filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]))
|
||||||
|
when :list
|
||||||
|
filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]))
|
||||||
|
when :mentions
|
||||||
|
filter_from_mentions?(status, receiver.id)
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Add a status to a home feed and send a streaming API update
|
||||||
|
# @param [Account] account
|
||||||
|
# @param [Status] status
|
||||||
|
# @return [Boolean]
|
||||||
def push_to_home(account, status)
|
def push_to_home(account, status)
|
||||||
return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
|
return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
|
||||||
|
|
||||||
@ -39,6 +62,10 @@ class FeedManager
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Remove a status from a home feed and send a streaming API update
|
||||||
|
# @param [Account] account
|
||||||
|
# @param [Status] status
|
||||||
|
# @return [Boolean]
|
||||||
def unpush_from_home(account, status)
|
def unpush_from_home(account, status)
|
||||||
return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
|
return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
|
||||||
|
|
||||||
@ -46,21 +73,22 @@ class FeedManager
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Add a status to a list feed and send a streaming API update
|
||||||
|
# @param [List] list
|
||||||
|
# @param [Status] status
|
||||||
|
# @return [Boolean]
|
||||||
def push_to_list(list, status)
|
def push_to_list(list, status)
|
||||||
if status.reply? && status.in_reply_to_account_id != status.account_id
|
return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
|
||||||
should_filter = status.in_reply_to_account_id != list.account_id
|
|
||||||
should_filter &&= !list.show_all_replies?
|
|
||||||
should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?)
|
|
||||||
return false if should_filter
|
|
||||||
end
|
|
||||||
|
|
||||||
return false unless add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
|
|
||||||
|
|
||||||
trim(:list, list.id)
|
trim(:list, list.id)
|
||||||
PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}")
|
PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}")
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Remove a status from a list feed and send a streaming API update
|
||||||
|
# @param [List] list
|
||||||
|
# @param [Status] status
|
||||||
|
# @return [Boolean]
|
||||||
def unpush_from_list(list, status)
|
def unpush_from_list(list, status)
|
||||||
return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
|
return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
|
||||||
|
|
||||||
@ -68,34 +96,11 @@ class FeedManager
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def trim(type, account_id)
|
# Fill a home feed with an account's statuses
|
||||||
timeline_key = key(type, account_id)
|
# @param [Account] from_account
|
||||||
reblog_key = key(type, account_id, 'reblogs')
|
# @param [Account] into_account
|
||||||
|
# @return [void]
|
||||||
# Remove any items past the MAX_ITEMS'th entry in our feed
|
def merge_into_home(from_account, into_account)
|
||||||
redis.zremrangebyrank(timeline_key, 0, -(FeedManager::MAX_ITEMS + 1))
|
|
||||||
|
|
||||||
# Get the score of the REBLOG_FALLOFF'th item in our feed, and stop
|
|
||||||
# tracking anything after it for deduplication purposes.
|
|
||||||
falloff_rank = FeedManager::REBLOG_FALLOFF
|
|
||||||
falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true)
|
|
||||||
falloff_score = falloff_range&.first&.last&.to_i
|
|
||||||
|
|
||||||
return if falloff_score.nil?
|
|
||||||
|
|
||||||
# Get any reblogs we might have to clean up after.
|
|
||||||
redis.zrangebyscore(reblog_key, 0, falloff_score).each do |reblogged_id|
|
|
||||||
# Remove it from the set of reblogs we're tracking *first* to avoid races.
|
|
||||||
redis.zrem(reblog_key, reblogged_id)
|
|
||||||
# Just drop any set we might have created to track additional reblogs.
|
|
||||||
# This means that if this reblog is deleted, we won't automatically insert
|
|
||||||
# another reblog, but also that any new reblog can be inserted into the
|
|
||||||
# feed.
|
|
||||||
redis.del(key(type, account_id, "reblogs:#{reblogged_id}"))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def merge_into_timeline(from_account, into_account)
|
|
||||||
timeline_key = key(:home, into_account.id)
|
timeline_key = key(:home, into_account.id)
|
||||||
aggregate = into_account.user&.aggregates_reblogs?
|
aggregate = into_account.user&.aggregates_reblogs?
|
||||||
query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
|
query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
|
||||||
@ -117,7 +122,37 @@ class FeedManager
|
|||||||
trim(:home, into_account.id)
|
trim(:home, into_account.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def unmerge_from_timeline(from_account, into_account)
|
# Fill a list feed with an account's statuses
|
||||||
|
# @param [Account] from_account
|
||||||
|
# @param [List] list
|
||||||
|
# @return [void]
|
||||||
|
def merge_into_list(from_account, list)
|
||||||
|
timeline_key = key(:list, list.id)
|
||||||
|
aggregate = list.account.user&.aggregates_reblogs?
|
||||||
|
query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4)
|
||||||
|
|
||||||
|
if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
|
||||||
|
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i
|
||||||
|
query = query.where('id > ?', oldest_home_score)
|
||||||
|
end
|
||||||
|
|
||||||
|
statuses = query.to_a
|
||||||
|
crutches = build_crutches(list.account_id, statuses)
|
||||||
|
|
||||||
|
statuses.each do |status|
|
||||||
|
next if filter_from_home?(status, list.account_id, crutches) || filter_from_list?(status, list)
|
||||||
|
|
||||||
|
add_to_feed(:list, list.id, status, aggregate)
|
||||||
|
end
|
||||||
|
|
||||||
|
trim(:list, list.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Remove an account's statuses from a home feed
|
||||||
|
# @param [Account] from_account
|
||||||
|
# @param [Account] into_account
|
||||||
|
# @return [void]
|
||||||
|
def unmerge_from_home(from_account, into_account)
|
||||||
timeline_key = key(:home, into_account.id)
|
timeline_key = key(:home, into_account.id)
|
||||||
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
|
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
|
||||||
|
|
||||||
@ -126,14 +161,31 @@ class FeedManager
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_from_timeline(account, target_account)
|
# Remove an account's statuses from a list feed
|
||||||
# Clear from timeline all statuses from or mentionning target_account
|
# @param [Account] from_account
|
||||||
|
# @param [List] list
|
||||||
|
# @return [void]
|
||||||
|
def unmerge_from_list(from_account, list)
|
||||||
|
timeline_key = key(:list, list.id)
|
||||||
|
oldest_list_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
|
||||||
|
|
||||||
|
from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_list_score).reorder(nil).find_each do |status|
|
||||||
|
remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clear all statuses from or mentioning target_account from a home feed
|
||||||
|
# @param [Account] account
|
||||||
|
# @param [Account] target_account
|
||||||
|
# @return [void]
|
||||||
|
def clear_from_home(account, target_account)
|
||||||
timeline_key = key(:home, account.id)
|
timeline_key = key(:home, account.id)
|
||||||
timeline_status_ids = redis.zrange(timeline_key, 0, -1)
|
timeline_status_ids = redis.zrange(timeline_key, 0, -1)
|
||||||
statuses = Status.where(id: timeline_status_ids).select(:id, :reblog_of_id, :account_id).to_a
|
statuses = Status.where(id: timeline_status_ids).select(:id, :reblog_of_id, :account_id).to_a
|
||||||
reblogged_ids = Status.where(id: statuses.map(&:reblog_of_id).compact, account: target_account).pluck(:id)
|
reblogged_ids = Status.where(id: statuses.map(&:reblog_of_id).compact, account: target_account).pluck(:id)
|
||||||
with_mentions_ids = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact, account: target_account).pluck(:status_id)
|
with_mentions_ids = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact, account: target_account).pluck(:status_id)
|
||||||
target_statuses = statuses.filter do |status|
|
|
||||||
|
target_statuses = statuses.select do |status|
|
||||||
status.account_id == target_account.id || reblogged_ids.include?(status.reblog_of_id) || with_mentions_ids.include?(status.id) || with_mentions_ids.include?(status.reblog_of_id)
|
status.account_id == target_account.id || reblogged_ids.include?(status.reblog_of_id) || with_mentions_ids.include?(status.id) || with_mentions_ids.include?(status.reblog_of_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -142,7 +194,10 @@ class FeedManager
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def populate_feed(account)
|
# Populate home feed of account from scratch
|
||||||
|
# @param [Account] account
|
||||||
|
# @return [void]
|
||||||
|
def populate_home(account)
|
||||||
limit = FeedManager::MAX_ITEMS / 2
|
limit = FeedManager::MAX_ITEMS / 2
|
||||||
aggregate = account.user&.aggregates_reblogs?
|
aggregate = account.user&.aggregates_reblogs?
|
||||||
timeline_key = key(:home, account.id)
|
timeline_key = key(:home, account.id)
|
||||||
@ -177,15 +232,59 @@ class FeedManager
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def push_update_required?(timeline_id)
|
# Trim a feed to maximum size by removing older items
|
||||||
redis.exists?("subscribed:#{timeline_id}")
|
# @param [Symbol] type
|
||||||
|
# @param [Integer] timeline_id
|
||||||
|
# @return [void]
|
||||||
|
def trim(type, timeline_id)
|
||||||
|
timeline_key = key(type, timeline_id)
|
||||||
|
reblog_key = key(type, timeline_id, 'reblogs')
|
||||||
|
|
||||||
|
# Remove any items past the MAX_ITEMS'th entry in our feed
|
||||||
|
redis.zremrangebyrank(timeline_key, 0, -(FeedManager::MAX_ITEMS + 1))
|
||||||
|
|
||||||
|
# Get the score of the REBLOG_FALLOFF'th item in our feed, and stop
|
||||||
|
# tracking anything after it for deduplication purposes.
|
||||||
|
falloff_rank = FeedManager::REBLOG_FALLOFF
|
||||||
|
falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true)
|
||||||
|
falloff_score = falloff_range&.first&.last&.to_i
|
||||||
|
|
||||||
|
return if falloff_score.nil?
|
||||||
|
|
||||||
|
# Get any reblogs we might have to clean up after.
|
||||||
|
redis.zrangebyscore(reblog_key, 0, falloff_score).each do |reblogged_id|
|
||||||
|
# Remove it from the set of reblogs we're tracking *first* to avoid races.
|
||||||
|
redis.zrem(reblog_key, reblogged_id)
|
||||||
|
# Just drop any set we might have created to track additional reblogs.
|
||||||
|
# This means that if this reblog is deleted, we won't automatically insert
|
||||||
|
# another reblog, but also that any new reblog can be inserted into the
|
||||||
|
# feed.
|
||||||
|
redis.del(key(type, timeline_id, "reblogs:#{reblogged_id}"))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if there is a streaming API client connected
|
||||||
|
# for the given feed
|
||||||
|
# @param [String] timeline_key
|
||||||
|
# @return [Boolean]
|
||||||
|
def push_update_required?(timeline_key)
|
||||||
|
redis.exists?("subscribed:#{timeline_key}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if the account is blocking or muting any of the given accounts
|
||||||
|
# @param [Integer] receiver_id
|
||||||
|
# @param [Array<Integer>] account_ids
|
||||||
|
# @param [Symbol] context
|
||||||
def blocks_or_mutes?(receiver_id, account_ids, context)
|
def blocks_or_mutes?(receiver_id, account_ids, context)
|
||||||
Block.where(account_id: receiver_id, target_account_id: account_ids).any? ||
|
Block.where(account_id: receiver_id, target_account_id: account_ids).any? ||
|
||||||
(context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?)
|
(context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if status should not be added to the home feed
|
||||||
|
# @param [Status] status
|
||||||
|
# @param [Integer] receiver_id
|
||||||
|
# @param [Hash] crutches
|
||||||
|
# @return [Boolean]
|
||||||
def filter_from_home?(status, receiver_id, crutches)
|
def filter_from_home?(status, receiver_id, crutches)
|
||||||
return false if receiver_id == status.account_id
|
return false if receiver_id == status.account_id
|
||||||
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
|
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
|
||||||
@ -218,6 +317,11 @@ class FeedManager
|
|||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if status should not be added to the mentions feed
|
||||||
|
# @see NotifyService
|
||||||
|
# @param [Status] status
|
||||||
|
# @param [Integer] receiver_id
|
||||||
|
# @return [Boolean]
|
||||||
def filter_from_mentions?(status, receiver_id)
|
def filter_from_mentions?(status, receiver_id)
|
||||||
return true if receiver_id == status.account_id
|
return true if receiver_id == status.account_id
|
||||||
return true if phrase_filtered?(status, receiver_id, :notifications)
|
return true if phrase_filtered?(status, receiver_id, :notifications)
|
||||||
@ -234,6 +338,27 @@ class FeedManager
|
|||||||
should_filter
|
should_filter
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if status should not be added to the list feed
|
||||||
|
# @param [Status] status
|
||||||
|
# @param [List] list
|
||||||
|
# @return [Boolean]
|
||||||
|
def filter_from_list?(status, list)
|
||||||
|
if status.reply? && status.in_reply_to_account_id != status.account_id
|
||||||
|
should_filter = status.in_reply_to_account_id != list.account_id
|
||||||
|
should_filter &&= !list.show_all_replies?
|
||||||
|
should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?)
|
||||||
|
|
||||||
|
return !!should_filter
|
||||||
|
end
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if the status hits a phrase filter
|
||||||
|
# @param [Status] status
|
||||||
|
# @param [Integer] receiver_id
|
||||||
|
# @param [Symbol] context
|
||||||
|
# @return [Boolean]
|
||||||
def phrase_filtered?(status, receiver_id, context)
|
def phrase_filtered?(status, receiver_id, context)
|
||||||
active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a
|
active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a
|
||||||
|
|
||||||
@ -269,6 +394,11 @@ class FeedManager
|
|||||||
# added, and false if it was not added to the feed. Note that this is
|
# added, and false if it was not added to the feed. Note that this is
|
||||||
# an internal helper: callers must call trim or push updates if
|
# an internal helper: callers must call trim or push updates if
|
||||||
# either action is appropriate.
|
# either action is appropriate.
|
||||||
|
# @param [Symbol] timeline_type
|
||||||
|
# @param [Integer] account_id
|
||||||
|
# @param [Status] status
|
||||||
|
# @param [Boolean] aggregate_reblogs
|
||||||
|
# @return [Boolean]
|
||||||
def add_to_feed(timeline_type, account_id, status, aggregate_reblogs = true)
|
def add_to_feed(timeline_type, account_id, status, aggregate_reblogs = true)
|
||||||
timeline_key = key(timeline_type, account_id)
|
timeline_key = key(timeline_type, account_id)
|
||||||
reblog_key = key(timeline_type, account_id, 'reblogs')
|
reblog_key = key(timeline_type, account_id, 'reblogs')
|
||||||
@ -312,6 +442,11 @@ class FeedManager
|
|||||||
# with reblogs, and returning true if a status was removed. As with
|
# with reblogs, and returning true if a status was removed. As with
|
||||||
# `add_to_feed`, this does not trigger push updates, so callers must
|
# `add_to_feed`, this does not trigger push updates, so callers must
|
||||||
# do so if appropriate.
|
# do so if appropriate.
|
||||||
|
# @param [Symbol] timeline_type
|
||||||
|
# @param [Integer] account_id
|
||||||
|
# @param [Status] status
|
||||||
|
# @param [Boolean] aggregate_reblogs
|
||||||
|
# @return [Boolean]
|
||||||
def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true)
|
def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true)
|
||||||
timeline_key = key(timeline_type, account_id)
|
timeline_key = key(timeline_type, account_id)
|
||||||
reblog_key = key(timeline_type, account_id, 'reblogs')
|
reblog_key = key(timeline_type, account_id, 'reblogs')
|
||||||
@ -346,6 +481,11 @@ class FeedManager
|
|||||||
redis.zrem(timeline_key, status.id)
|
redis.zrem(timeline_key, status.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Pre-fetch various objects and relationships for given statuses that
|
||||||
|
# are going to be checked by the filtering methods
|
||||||
|
# @param [Integer] receiver_id
|
||||||
|
# @param [Array<Status>] statuses
|
||||||
|
# @return [Hash]
|
||||||
def build_crutches(receiver_id, statuses)
|
def build_crutches(receiver_id, statuses)
|
||||||
crutches = {}
|
crutches = {}
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ class AfterBlockService < BaseService
|
|||||||
private
|
private
|
||||||
|
|
||||||
def clear_home_feed!
|
def clear_home_feed!
|
||||||
FeedManager.instance.clear_from_timeline(@account, @target_account)
|
FeedManager.instance.clear_from_home(@account, @target_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_conversations!
|
def clear_conversations!
|
||||||
|
@ -13,15 +13,13 @@ class NotifyService < BaseService
|
|||||||
push_to_conversation! if direct_message?
|
push_to_conversation! if direct_message?
|
||||||
send_email! if email_enabled?
|
send_email! if email_enabled?
|
||||||
rescue ActiveRecord::RecordInvalid
|
rescue ActiveRecord::RecordInvalid
|
||||||
# rubocop:disable Style/RedundantReturn
|
nil
|
||||||
return
|
|
||||||
# rubocop:enable Style/RedundantReturn
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def blocked_mention?
|
def blocked_mention?
|
||||||
FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient.id)
|
FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient)
|
||||||
end
|
end
|
||||||
|
|
||||||
def blocked_favourite?
|
def blocked_favourite?
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
class PrecomputeFeedService < BaseService
|
class PrecomputeFeedService < BaseService
|
||||||
def call(account)
|
def call(account)
|
||||||
FeedManager.instance.populate_feed(account)
|
FeedManager.instance.populate_home(account)
|
||||||
ensure
|
ensure
|
||||||
Redis.current.del("account:#{account.id}:regeneration")
|
Redis.current.del("account:#{account.id}:regeneration")
|
||||||
end
|
end
|
||||||
|
@ -27,9 +27,12 @@ class FeedInsertWorker
|
|||||||
end
|
end
|
||||||
|
|
||||||
def feed_filtered?
|
def feed_filtered?
|
||||||
# Note: Lists are a variation of home, so the filtering rules
|
case @type
|
||||||
# of home apply to both
|
when :home
|
||||||
FeedManager.instance.filter?(:home, @status, @follower.id)
|
FeedManager.instance.filter?(:home, @status, @follower)
|
||||||
|
when :list
|
||||||
|
FeedManager.instance.filter?(:list, @status, @list)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform_push
|
def perform_push
|
||||||
|
@ -6,6 +6,8 @@ class MergeWorker
|
|||||||
sidekiq_options queue: 'pull'
|
sidekiq_options queue: 'pull'
|
||||||
|
|
||||||
def perform(from_account_id, into_account_id)
|
def perform(from_account_id, into_account_id)
|
||||||
FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id))
|
FeedManager.instance.merge_into_home(Account.find(from_account_id), Account.find(into_account_id))
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -4,9 +4,8 @@ class MuteWorker
|
|||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
def perform(account_id, target_account_id)
|
def perform(account_id, target_account_id)
|
||||||
FeedManager.instance.clear_from_timeline(
|
FeedManager.instance.clear_from_home(Account.find(account_id), Account.find(target_account_id))
|
||||||
Account.find(account_id),
|
rescue ActiveRecord::RecordNotFound
|
||||||
Account.find(target_account_id)
|
true
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -6,6 +6,8 @@ class UnmergeWorker
|
|||||||
sidekiq_options queue: 'pull'
|
sidekiq_options queue: 'pull'
|
||||||
|
|
||||||
def perform(from_account_id, into_account_id)
|
def perform(from_account_id, into_account_id)
|
||||||
FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id))
|
FeedManager.instance.unmerge_from_home(Account.find(from_account_id), Account.find(into_account_id))
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -29,14 +29,14 @@ RSpec.describe FeedManager do
|
|||||||
it 'returns false for followee\'s status' do
|
it 'returns false for followee\'s status' do
|
||||||
status = Fabricate(:status, text: 'Hello world', account: alice)
|
status = Fabricate(:status, text: 'Hello world', account: alice)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
expect(FeedManager.instance.filter?(:home, status, bob.id)).to be false
|
expect(FeedManager.instance.filter?(:home, status, bob)).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false for reblog by followee' do
|
it 'returns false for reblog by followee' do
|
||||||
status = Fabricate(:status, text: 'Hello world', account: jeff)
|
status = Fabricate(:status, text: 'Hello world', account: jeff)
|
||||||
reblog = Fabricate(:status, reblog: status, account: alice)
|
reblog = Fabricate(:status, reblog: status, account: alice)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be false
|
expect(FeedManager.instance.filter?(:home, reblog, bob)).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true for reblog by followee of blocked account' do
|
it 'returns true for reblog by followee of blocked account' do
|
||||||
@ -44,7 +44,7 @@ RSpec.describe FeedManager do
|
|||||||
reblog = Fabricate(:status, reblog: status, account: alice)
|
reblog = Fabricate(:status, reblog: status, account: alice)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
bob.block!(jeff)
|
bob.block!(jeff)
|
||||||
expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true
|
expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true for reblog by followee of muted account' do
|
it 'returns true for reblog by followee of muted account' do
|
||||||
@ -52,7 +52,7 @@ RSpec.describe FeedManager do
|
|||||||
reblog = Fabricate(:status, reblog: status, account: alice)
|
reblog = Fabricate(:status, reblog: status, account: alice)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
bob.mute!(jeff)
|
bob.mute!(jeff)
|
||||||
expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true
|
expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true for reblog by followee of someone who is blocking recipient' do
|
it 'returns true for reblog by followee of someone who is blocking recipient' do
|
||||||
@ -60,14 +60,14 @@ RSpec.describe FeedManager do
|
|||||||
reblog = Fabricate(:status, reblog: status, account: alice)
|
reblog = Fabricate(:status, reblog: status, account: alice)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
jeff.block!(bob)
|
jeff.block!(bob)
|
||||||
expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true
|
expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true for reblog from account with reblogs disabled' do
|
it 'returns true for reblog from account with reblogs disabled' do
|
||||||
status = Fabricate(:status, text: 'Hello world', account: jeff)
|
status = Fabricate(:status, text: 'Hello world', account: jeff)
|
||||||
reblog = Fabricate(:status, reblog: status, account: alice)
|
reblog = Fabricate(:status, reblog: status, account: alice)
|
||||||
bob.follow!(alice, reblogs: false)
|
bob.follow!(alice, reblogs: false)
|
||||||
expect(FeedManager.instance.filter?(:home, reblog, bob.id)).to be true
|
expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false for reply by followee to another followee' do
|
it 'returns false for reply by followee to another followee' do
|
||||||
@ -75,48 +75,48 @@ RSpec.describe FeedManager do
|
|||||||
reply = Fabricate(:status, text: 'Nay', thread: status, account: alice)
|
reply = Fabricate(:status, text: 'Nay', thread: status, account: alice)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
bob.follow!(jeff)
|
bob.follow!(jeff)
|
||||||
expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be false
|
expect(FeedManager.instance.filter?(:home, reply, bob)).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false for reply by followee to recipient' do
|
it 'returns false for reply by followee to recipient' do
|
||||||
status = Fabricate(:status, text: 'Hello world', account: bob)
|
status = Fabricate(:status, text: 'Hello world', account: bob)
|
||||||
reply = Fabricate(:status, text: 'Nay', thread: status, account: alice)
|
reply = Fabricate(:status, text: 'Nay', thread: status, account: alice)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be false
|
expect(FeedManager.instance.filter?(:home, reply, bob)).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false for reply by followee to self' do
|
it 'returns false for reply by followee to self' do
|
||||||
status = Fabricate(:status, text: 'Hello world', account: alice)
|
status = Fabricate(:status, text: 'Hello world', account: alice)
|
||||||
reply = Fabricate(:status, text: 'Nay', thread: status, account: alice)
|
reply = Fabricate(:status, text: 'Nay', thread: status, account: alice)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be false
|
expect(FeedManager.instance.filter?(:home, reply, bob)).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true for reply by followee to non-followed account' do
|
it 'returns true for reply by followee to non-followed account' do
|
||||||
status = Fabricate(:status, text: 'Hello world', account: jeff)
|
status = Fabricate(:status, text: 'Hello world', account: jeff)
|
||||||
reply = Fabricate(:status, text: 'Nay', thread: status, account: alice)
|
reply = Fabricate(:status, text: 'Nay', thread: status, account: alice)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be true
|
expect(FeedManager.instance.filter?(:home, reply, bob)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true for the second reply by followee to a non-federated status' do
|
it 'returns true for the second reply by followee to a non-federated status' do
|
||||||
reply = Fabricate(:status, text: 'Reply 1', reply: true, account: alice)
|
reply = Fabricate(:status, text: 'Reply 1', reply: true, account: alice)
|
||||||
second_reply = Fabricate(:status, text: 'Reply 2', thread: reply, account: alice)
|
second_reply = Fabricate(:status, text: 'Reply 2', thread: reply, account: alice)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
expect(FeedManager.instance.filter?(:home, second_reply, bob.id)).to be true
|
expect(FeedManager.instance.filter?(:home, second_reply, bob)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false for status by followee mentioning another account' do
|
it 'returns false for status by followee mentioning another account' do
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
status = PostStatusService.new.call(alice, text: 'Hey @jeff')
|
status = PostStatusService.new.call(alice, text: 'Hey @jeff')
|
||||||
expect(FeedManager.instance.filter?(:home, status, bob.id)).to be false
|
expect(FeedManager.instance.filter?(:home, status, bob)).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true for status by followee mentioning blocked account' do
|
it 'returns true for status by followee mentioning blocked account' do
|
||||||
bob.block!(jeff)
|
bob.block!(jeff)
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
status = PostStatusService.new.call(alice, text: 'Hey @jeff')
|
status = PostStatusService.new.call(alice, text: 'Hey @jeff')
|
||||||
expect(FeedManager.instance.filter?(:home, status, bob.id)).to be true
|
expect(FeedManager.instance.filter?(:home, status, bob)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true for reblog of a personally blocked domain' do
|
it 'returns true for reblog of a personally blocked domain' do
|
||||||
@ -124,7 +124,7 @@ RSpec.describe FeedManager do
|
|||||||
alice.follow!(jeff)
|
alice.follow!(jeff)
|
||||||
status = Fabricate(:status, text: 'Hello world', account: bob)
|
status = Fabricate(:status, text: 'Hello world', account: bob)
|
||||||
reblog = Fabricate(:status, reblog: status, account: jeff)
|
reblog = Fabricate(:status, reblog: status, account: jeff)
|
||||||
expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
|
expect(FeedManager.instance.filter?(:home, reblog, alice)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'for irreversibly muted phrases' do
|
context 'for irreversibly muted phrases' do
|
||||||
@ -132,7 +132,7 @@ RSpec.describe FeedManager do
|
|||||||
alice.custom_filters.create!(phrase: 'bob', context: %w(home), irreversible: true)
|
alice.custom_filters.create!(phrase: 'bob', context: %w(home), irreversible: true)
|
||||||
alice.follow!(jeff)
|
alice.follow!(jeff)
|
||||||
status = Fabricate(:status, text: 'bobcats', account: jeff)
|
status = Fabricate(:status, text: 'bobcats', account: jeff)
|
||||||
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be_falsy
|
expect(FeedManager.instance.filter?(:home, status, alice)).to be_falsy
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true if phrase is contained' do
|
it 'returns true if phrase is contained' do
|
||||||
@ -140,14 +140,14 @@ RSpec.describe FeedManager do
|
|||||||
alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
|
alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
|
||||||
alice.follow!(jeff)
|
alice.follow!(jeff)
|
||||||
status = Fabricate(:status, text: 'i sure like POP TARts', account: jeff)
|
status = Fabricate(:status, text: 'i sure like POP TARts', account: jeff)
|
||||||
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
|
expect(FeedManager.instance.filter?(:home, status, alice)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'matches substrings if whole_word is false' do
|
it 'matches substrings if whole_word is false' do
|
||||||
alice.custom_filters.create!(phrase: 'take', context: %w(home), whole_word: false, irreversible: true)
|
alice.custom_filters.create!(phrase: 'take', context: %w(home), whole_word: false, irreversible: true)
|
||||||
alice.follow!(jeff)
|
alice.follow!(jeff)
|
||||||
status = Fabricate(:status, text: 'shiitake', account: jeff)
|
status = Fabricate(:status, text: 'shiitake', account: jeff)
|
||||||
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
|
expect(FeedManager.instance.filter?(:home, status, alice)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true if phrase is contained in a poll option' do
|
it 'returns true if phrase is contained in a poll option' do
|
||||||
@ -155,7 +155,7 @@ RSpec.describe FeedManager do
|
|||||||
alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
|
alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
|
||||||
alice.follow!(jeff)
|
alice.follow!(jeff)
|
||||||
status = Fabricate(:status, text: 'what do you prefer', poll: Fabricate(:poll, options: %w(farts POP TARts)), account: jeff)
|
status = Fabricate(:status, text: 'what do you prefer', poll: Fabricate(:poll, options: %w(farts POP TARts)), account: jeff)
|
||||||
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
|
expect(FeedManager.instance.filter?(:home, status, alice)).to be true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -164,27 +164,27 @@ RSpec.describe FeedManager do
|
|||||||
it 'returns true for status that mentions blocked account' do
|
it 'returns true for status that mentions blocked account' do
|
||||||
bob.block!(jeff)
|
bob.block!(jeff)
|
||||||
status = PostStatusService.new.call(alice, text: 'Hey @jeff')
|
status = PostStatusService.new.call(alice, text: 'Hey @jeff')
|
||||||
expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
|
expect(FeedManager.instance.filter?(:mentions, status, bob)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true for status that replies to a blocked account' do
|
it 'returns true for status that replies to a blocked account' do
|
||||||
status = Fabricate(:status, text: 'Hello world', account: jeff)
|
status = Fabricate(:status, text: 'Hello world', account: jeff)
|
||||||
reply = Fabricate(:status, text: 'Nay', thread: status, account: alice)
|
reply = Fabricate(:status, text: 'Nay', thread: status, account: alice)
|
||||||
bob.block!(jeff)
|
bob.block!(jeff)
|
||||||
expect(FeedManager.instance.filter?(:mentions, reply, bob.id)).to be true
|
expect(FeedManager.instance.filter?(:mentions, reply, bob)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true for status by silenced account who recipient is not following' do
|
it 'returns true for status by silenced account who recipient is not following' do
|
||||||
status = Fabricate(:status, text: 'Hello world', account: alice)
|
status = Fabricate(:status, text: 'Hello world', account: alice)
|
||||||
alice.silence!
|
alice.silence!
|
||||||
expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
|
expect(FeedManager.instance.filter?(:mentions, status, bob)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false for status by followed silenced account' do
|
it 'returns false for status by followed silenced account' do
|
||||||
status = Fabricate(:status, text: 'Hello world', account: alice)
|
status = Fabricate(:status, text: 'Hello world', account: alice)
|
||||||
alice.silence!
|
alice.silence!
|
||||||
bob.follow!(alice)
|
bob.follow!(alice)
|
||||||
expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false
|
expect(FeedManager.instance.filter?(:mentions, status, bob)).to be false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -414,52 +414,20 @@ RSpec.describe FeedManager do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#merge_into_timeline' do
|
describe '#merge_into_home' do
|
||||||
it "does not push source account's statuses whose reblogs are already inserted" do
|
it "does not push source account's statuses whose reblogs are already inserted" do
|
||||||
account = Fabricate(:account, id: 0)
|
account = Fabricate(:account, id: 0)
|
||||||
reblog = Fabricate(:status)
|
reblog = Fabricate(:status)
|
||||||
status = Fabricate(:status, reblog: reblog)
|
status = Fabricate(:status, reblog: reblog)
|
||||||
FeedManager.instance.push_to_home(account, status)
|
FeedManager.instance.push_to_home(account, status)
|
||||||
|
|
||||||
FeedManager.instance.merge_into_timeline(account, reblog.account)
|
FeedManager.instance.merge_into_home(account, reblog.account)
|
||||||
|
|
||||||
expect(Redis.current.zscore("feed:home:0", reblog.id)).to eq nil
|
expect(Redis.current.zscore("feed:home:0", reblog.id)).to eq nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#trim' do
|
describe '#unpush_from_home' do
|
||||||
let(:receiver) { Fabricate(:account) }
|
|
||||||
|
|
||||||
it 'cleans up reblog tracking keys' do
|
|
||||||
reblogged = Fabricate(:status)
|
|
||||||
status = Fabricate(:status, reblog: reblogged)
|
|
||||||
another_status = Fabricate(:status, reblog: reblogged)
|
|
||||||
reblogs_key = FeedManager.instance.key('home', receiver.id, 'reblogs')
|
|
||||||
reblog_set_key = FeedManager.instance.key('home', receiver.id, "reblogs:#{reblogged.id}")
|
|
||||||
|
|
||||||
FeedManager.instance.push_to_home(receiver, status)
|
|
||||||
FeedManager.instance.push_to_home(receiver, another_status)
|
|
||||||
|
|
||||||
# We should have a tracking set and an entry in reblogs.
|
|
||||||
expect(Redis.current.exists?(reblog_set_key)).to be true
|
|
||||||
expect(Redis.current.zrange(reblogs_key, 0, -1)).to eq [reblogged.id.to_s]
|
|
||||||
|
|
||||||
# Push everything past the reblog falloff.
|
|
||||||
FeedManager::REBLOG_FALLOFF.times do
|
|
||||||
FeedManager.instance.push_to_home(receiver, Fabricate(:status))
|
|
||||||
end
|
|
||||||
|
|
||||||
# `trim` should be called automatically, but do it anyway, as
|
|
||||||
# we're testing `trim`, not side effects of `push`.
|
|
||||||
FeedManager.instance.trim('home', receiver.id)
|
|
||||||
|
|
||||||
# We should not have any reblog tracking data.
|
|
||||||
expect(Redis.current.exists?(reblog_set_key)).to be false
|
|
||||||
expect(Redis.current.zrange(reblogs_key, 0, -1)).to be_empty
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#unpush' do
|
|
||||||
let(:receiver) { Fabricate(:account) }
|
let(:receiver) { Fabricate(:account) }
|
||||||
|
|
||||||
it 'leaves a reblogged status if original was on feed' do
|
it 'leaves a reblogged status if original was on feed' do
|
||||||
@ -525,7 +493,7 @@ RSpec.describe FeedManager do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#clear_from_timeline' do
|
describe '#clear_from_home' do
|
||||||
let(:account) { Fabricate(:account) }
|
let(:account) { Fabricate(:account) }
|
||||||
let(:followed_account) { Fabricate(:account) }
|
let(:followed_account) { Fabricate(:account) }
|
||||||
let(:target_account) { Fabricate(:account) }
|
let(:target_account) { Fabricate(:account) }
|
||||||
@ -543,8 +511,8 @@ RSpec.describe FeedManager do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'correctly cleans the timeline' do
|
it 'correctly cleans the home timeline' do
|
||||||
FeedManager.instance.clear_from_timeline(account, target_account)
|
FeedManager.instance.clear_from_home(account, target_account)
|
||||||
|
|
||||||
expect(Redis.current.zrange("feed:home:#{account.id}", 0, -1)).to eq [status_1.id.to_s, status_7.id.to_s]
|
expect(Redis.current.zrange("feed:home:#{account.id}", 0, -1)).to eq [status_1.id.to_s, status_7.id.to_s]
|
||||||
end
|
end
|
||||||
|
Loading…
Reference in New Issue
Block a user