]> cat aescling's git repositories - mastodon.git/blob - app/lib/feed_manager.rb
Merge remote-tracking branch 'origin/master' into gs-master
[mastodon.git] / app / lib / feed_manager.rb
1 # frozen_string_literal: true
2
3 require 'singleton'
4
5 class FeedManager
6 include Singleton
7
8 MAX_ITEMS = 400
9
10 # Must be <= MAX_ITEMS or the tracking sets will grow forever
11 REBLOG_FALLOFF = 40
12
13 def key(type, id, subtype = nil)
14 return "feed:#{type}:#{id}" unless subtype
15
16 "feed:#{type}:#{id}:#{subtype}"
17 end
18
19 def filter?(timeline_type, status, receiver_id)
20 if timeline_type == :home
21 filter_from_home?(status, receiver_id)
22 elsif timeline_type == :mentions
23 filter_from_mentions?(status, receiver_id)
24 else
25 false
26 end
27 end
28
29 def push_to_home(account, status)
30 return false unless add_to_feed(:home, account.id, status)
31 trim(:home, account.id)
32 PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}")
33 true
34 end
35
36 def unpush_from_home(account, status)
37 return false unless remove_from_feed(:home, account.id, status)
38 Redis.current.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
39 true
40 end
41
42 def push_to_list(list, status)
43 return false unless add_to_feed(:list, list.id, status)
44 trim(:list, list.id)
45 PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}")
46 true
47 end
48
49 def unpush_from_list(list, status)
50 return false unless remove_from_feed(:list, list.id, status)
51 Redis.current.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
52 true
53 end
54
55 def trim(type, account_id)
56 timeline_key = key(type, account_id)
57 reblog_key = key(type, account_id, 'reblogs')
58
59 # Remove any items past the MAX_ITEMS'th entry in our feed
60 redis.zremrangebyrank(timeline_key, '0', (-(FeedManager::MAX_ITEMS + 1)).to_s)
61
62 # Get the score of the REBLOG_FALLOFF'th item in our feed, and stop
63 # tracking anything after it for deduplication purposes.
64 falloff_rank = FeedManager::REBLOG_FALLOFF - 1
65 falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true)
66 falloff_score = falloff_range&.first&.last&.to_i || 0
67
68 # Get any reblogs we might have to clean up after.
69 redis.zrangebyscore(reblog_key, 0, falloff_score).each do |reblogged_id|
70 # Remove it from the set of reblogs we're tracking *first* to avoid races.
71 redis.zrem(reblog_key, reblogged_id)
72 # Just drop any set we might have created to track additional reblogs.
73 # This means that if this reblog is deleted, we won't automatically insert
74 # another reblog, but also that any new reblog can be inserted into the
75 # feed.
76 redis.del(key(type, account_id, "reblogs:#{reblogged_id}"))
77 end
78 end
79
80 def merge_into_timeline(from_account, into_account)
81 timeline_key = key(:home, into_account.id)
82 query = from_account.statuses.limit(FeedManager::MAX_ITEMS / 4)
83
84 if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
85 oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
86 query = query.where('id > ?', oldest_home_score)
87 end
88
89 query.each do |status|
90 next if status.direct_visibility? || filter?(:home, status, into_account)
91 add_to_feed(:home, into_account.id, status)
92 end
93
94 trim(:home, into_account.id)
95 end
96
97 def unmerge_from_timeline(from_account, into_account)
98 timeline_key = key(:home, into_account.id)
99 oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
100
101 from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status|
102 remove_from_feed(:home, into_account.id, status)
103 end
104 end
105
106 def clear_from_timeline(account, target_account)
107 timeline_key = key(:home, account.id)
108 timeline_status_ids = redis.zrange(timeline_key, 0, -1)
109 target_statuses = Status.where(id: timeline_status_ids, account: target_account)
110
111 target_statuses.each do |status|
112 unpush_from_home(account, status)
113 end
114 end
115
116 def populate_feed(account)
117 added = 0
118 limit = FeedManager::MAX_ITEMS / 2
119 max_id = nil
120
121 loop do
122 statuses = Status.as_home_timeline(account)
123 .paginate_by_max_id(limit, max_id)
124
125 break if statuses.empty?
126
127 statuses.each do |status|
128 next if filter_from_home?(status, account)
129 added += 1 if add_to_feed(:home, account.id, status)
130 end
131
132 break unless added.zero?
133
134 max_id = statuses.last.id
135 end
136 end
137
138 private
139
140 def redis
141 Redis.current
142 end
143
144 def push_update_required?(timeline_id)
145 redis.exists("subscribed:#{timeline_id}")
146 end
147
148 def blocks_or_mutes?(receiver_id, account_ids, context)
149 Block.where(account_id: receiver_id, target_account_id: account_ids).any? ||
150 (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?)
151 end
152
153 def filter_from_home?(status, receiver_id)
154 return false if receiver_id == status.account_id
155 return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
156 return true if keyword_filter?(status, receiver_id)
157
158 check_for_mutes = [status.account_id]
159 check_for_mutes.concat(status.mentions.pluck(:account_id))
160 check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
161
162 return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any?
163
164 check_for_blocks = status.mentions.pluck(:account_id)
165 check_for_blocks.concat([status.account_id])
166 check_for_blocks.concat([status.reblog.account_id]) if status.reblog?
167
168 return true if blocks_or_mutes?(receiver_id, check_for_blocks, :home)
169
170 if status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply
171 should_filter = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists? # and I'm not following the person it's a reply to
172 should_filter &&= receiver_id != status.in_reply_to_account_id # and it's not a reply to me
173 should_filter &&= status.account_id != status.in_reply_to_account_id # and it's not a self-reply
174 return should_filter
175 elsif status.reblog? # Filter out a reblog
176 should_filter = Follow.where(account_id: receiver_id, target_account_id: status.account_id, show_reblogs: false).exists? # if the reblogger's reblogs are suppressed
177 should_filter ||= Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me
178 should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists? # or the author's domain is blocked
179 return should_filter
180 end
181
182 false
183 end
184
185 def keyword_filter?(status, receiver_id)
186 Glitch::KeywordMuteHelper.new(receiver_id).matches?(status)
187 end
188
189 def filter_from_mentions?(status, receiver_id)
190 return true if receiver_id == status.account_id
191
192 # This filter is called from NotifyService, but already after the sender of
193 # the notification has been checked for mute/block. Therefore, it's not
194 # necessary to check the author of the toot for mute/block again
195 check_for_blocks = status.mentions.pluck(:account_id)
196 check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
197
198 should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
199 should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
200 should_filter ||= keyword_filter?(status, receiver_id) # or if the mention contains a muted keyword
201
202 should_filter
203 end
204
205 # Adds a status to an account's feed, returning true if a status was
206 # added, and false if it was not added to the feed. Note that this is
207 # an internal helper: callers must call trim or push updates if
208 # either action is appropriate.
209 def add_to_feed(timeline_type, account_id, status)
210 timeline_key = key(timeline_type, account_id)
211 reblog_key = key(timeline_type, account_id, 'reblogs')
212
213 if status.reblog?
214 # If the original status or a reblog of it is within
215 # REBLOG_FALLOFF statuses from the top, do not re-insert it into
216 # the feed
217 rank = redis.zrevrank(timeline_key, status.reblog_of_id)
218
219 return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF
220
221 reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id)
222
223 if reblog_rank.nil?
224 # This is not something we've already seen reblogged, so we
225 # can just add it to the feed (and note that we're
226 # reblogging it).
227 redis.zadd(timeline_key, status.id, status.id)
228 redis.zadd(reblog_key, status.id, status.reblog_of_id)
229 else
230 # Another reblog of the same status was already in the
231 # REBLOG_FALLOFF most recent statuses, so we note that this
232 # is an "extra" reblog, by storing it in reblog_set_key.
233 reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
234 redis.sadd(reblog_set_key, status.id)
235 return false
236 end
237 else
238 # A reblog may reach earlier than the original status because of the
239 # delay of the worker deliverying the original status, the late addition
240 # by merging timelines, and other reasons.
241 # If such a reblog already exists, just do not re-insert it into the feed.
242 rank = redis.zrevrank(reblog_key, status.id)
243
244 return false unless rank.nil?
245
246 redis.zadd(timeline_key, status.id, status.id)
247 end
248
249 true
250 end
251
252 # Removes an individual status from a feed, correctly handling cases
253 # with reblogs, and returning true if a status was removed. As with
254 # `add_to_feed`, this does not trigger push updates, so callers must
255 # do so if appropriate.
256 def remove_from_feed(timeline_type, account_id, status)
257 timeline_key = key(timeline_type, account_id)
258
259 if status.reblog?
260 # 1. If the reblogging status is not in the feed, stop.
261 status_rank = redis.zrevrank(timeline_key, status.id)
262 return false if status_rank.nil?
263
264 # 2. Remove reblog from set of this status's reblogs.
265 reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
266
267 redis.srem(reblog_set_key, status.id)
268 # 3. Re-insert another reblog or original into the feed if one
269 # remains in the set. We could pick a random element, but this
270 # set should generally be small, and it seems ideal to show the
271 # oldest potential such reblog.
272 other_reblog = redis.smembers(reblog_set_key).map(&:to_i).sort.first
273
274 redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog
275
276 # 4. Remove the reblogging status from the feed (as normal)
277 # (outside conditional)
278 else
279 # If the original is getting deleted, no use for reblog references
280 redis.del(key(timeline_type, account_id, "reblogs:#{status.id}"))
281 end
282
283 redis.zrem(timeline_key, status.id)
284 end
285 end
This page took 0.266076 seconds and 4 git commands to generate.