2016-11-20 08:33:02 +09:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
class NotifyService < BaseService
|
2022-04-09 01:03:31 +09:00
|
|
|
include Redisable
|
|
|
|
|
2024-06-03 17:35:59 +09:00
|
|
|
MAXIMUM_GROUP_SPAN_HOURS = 12
|
|
|
|
|
2023-03-30 21:44:00 +09:00
|
|
|
NON_EMAIL_TYPES = %i(
|
|
|
|
admin.report
|
|
|
|
admin.sign_up
|
2023-04-08 19:51:14 +09:00
|
|
|
update
|
2023-04-17 20:13:36 +09:00
|
|
|
poll
|
2023-09-12 03:23:13 +09:00
|
|
|
status
|
2024-04-26 02:26:05 +09:00
|
|
|
moderation_warning
|
2024-03-21 00:37:21 +09:00
|
|
|
# TODO: this probably warrants an email notification
|
|
|
|
severed_relationships
|
2023-03-30 21:44:00 +09:00
|
|
|
).freeze
|
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
class DismissCondition
|
|
|
|
def initialize(notification)
|
|
|
|
@recipient = notification.account
|
|
|
|
@sender = notification.from_account
|
|
|
|
@notification = notification
|
|
|
|
end
|
2016-11-20 08:33:02 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def dismiss?
|
|
|
|
blocked = @recipient.unavailable?
|
2024-04-26 02:26:05 +09:00
|
|
|
blocked ||= from_self? && %i(poll severed_relationships moderation_warning).exclude?(@notification.type)
|
2016-11-20 08:33:02 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
return blocked if message? && from_staff?
|
2022-04-09 01:03:31 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
blocked ||= domain_blocking?
|
|
|
|
blocked ||= @recipient.blocking?(@sender)
|
|
|
|
blocked ||= @recipient.muting_notifications?(@sender)
|
|
|
|
blocked ||= conversation_muted?
|
|
|
|
blocked ||= blocked_mention? if message?
|
|
|
|
blocked
|
|
|
|
end
|
2022-04-09 01:03:31 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
private
|
2016-11-20 08:33:02 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def blocked_mention?
|
|
|
|
FeedManager.instance.filter?(:mentions, @notification.target_status, @recipient)
|
|
|
|
end
|
2016-11-20 08:33:02 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def message?
|
|
|
|
@notification.type == :mention
|
|
|
|
end
|
2016-11-21 18:37:34 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def from_staff?
|
|
|
|
@sender.local? && @sender.user.present? && @sender.user_role&.overrides?(@recipient.user_role)
|
|
|
|
end
|
2023-02-20 14:58:28 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def from_self?
|
|
|
|
@recipient.id == @sender.id
|
|
|
|
end
|
2017-11-15 05:12:57 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def domain_blocking?
|
|
|
|
@recipient.domain_blocking?(@sender.domain) && !following_sender?
|
|
|
|
end
|
2017-11-15 05:12:57 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def conversation_muted?
|
|
|
|
@notification.target_status && @recipient.muting_conversation?(@notification.target_status.conversation)
|
|
|
|
end
|
2017-11-15 05:12:57 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def following_sender?
|
|
|
|
@recipient.following?(@sender)
|
|
|
|
end
|
2018-10-30 23:02:55 +09:00
|
|
|
end
|
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
class FilterCondition
|
|
|
|
NEW_ACCOUNT_THRESHOLD = 30.days.freeze
|
2017-11-15 05:12:57 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
NEW_FOLLOWER_THRESHOLD = 3.days.freeze
|
2017-11-15 05:12:57 +09:00
|
|
|
|
2024-03-13 19:17:55 +09:00
|
|
|
NON_FILTERABLE_TYPES = %i(
|
|
|
|
admin.sign_up
|
|
|
|
admin.report
|
|
|
|
poll
|
|
|
|
update
|
2024-04-26 02:26:05 +09:00
|
|
|
account_warning
|
2024-03-13 19:17:55 +09:00
|
|
|
).freeze
|
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def initialize(notification)
|
|
|
|
@notification = notification
|
|
|
|
@recipient = notification.account
|
|
|
|
@sender = notification.from_account
|
|
|
|
@policy = NotificationPolicy.find_or_initialize_by(account: @recipient)
|
|
|
|
end
|
2018-10-17 02:55:05 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def filter?
|
2024-03-13 19:35:49 +09:00
|
|
|
return false unless Notification::PROPERTIES[@notification.type][:filterable]
|
2024-03-07 23:53:37 +09:00
|
|
|
return false if override_for_sender?
|
|
|
|
|
|
|
|
from_limited? ||
|
|
|
|
filtered_by_not_following_policy? ||
|
|
|
|
filtered_by_not_followers_policy? ||
|
|
|
|
filtered_by_new_accounts_policy? ||
|
|
|
|
filtered_by_private_mentions_policy?
|
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
def filtered_by_not_following_policy?
|
|
|
|
@policy.filter_not_following? && not_following?
|
|
|
|
end
|
|
|
|
|
|
|
|
def filtered_by_not_followers_policy?
|
|
|
|
@policy.filter_not_followers? && not_follower?
|
|
|
|
end
|
|
|
|
|
|
|
|
def filtered_by_new_accounts_policy?
|
|
|
|
@policy.filter_new_accounts? && new_account?
|
|
|
|
end
|
|
|
|
|
|
|
|
def filtered_by_private_mentions_policy?
|
|
|
|
@policy.filter_private_mentions? && not_following? && private_mention_not_in_response?
|
|
|
|
end
|
2017-11-15 05:12:57 +09:00
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def not_following?
|
|
|
|
!@recipient.following?(@sender)
|
|
|
|
end
|
|
|
|
|
|
|
|
def not_follower?
|
|
|
|
follow = Follow.find_by(account: @sender, target_account: @recipient)
|
|
|
|
follow.nil? || follow.created_at > NEW_FOLLOWER_THRESHOLD.ago
|
|
|
|
end
|
|
|
|
|
|
|
|
def new_account?
|
|
|
|
@sender.created_at > NEW_ACCOUNT_THRESHOLD.ago
|
|
|
|
end
|
|
|
|
|
|
|
|
def override_for_sender?
|
|
|
|
NotificationPermission.exists?(account: @recipient, from_account: @sender)
|
|
|
|
end
|
|
|
|
|
|
|
|
def from_limited?
|
|
|
|
@sender.silenced? && not_following?
|
|
|
|
end
|
|
|
|
|
|
|
|
def private_mention_not_in_response?
|
|
|
|
@notification.type == :mention && @notification.target_status.direct_visibility? && !response_to_recipient?
|
|
|
|
end
|
|
|
|
|
|
|
|
def response_to_recipient?
|
|
|
|
return false if @notification.target_status.in_reply_to_id.nil?
|
|
|
|
|
|
|
|
statuses_that_mention_sender.positive?
|
|
|
|
end
|
|
|
|
|
|
|
|
def statuses_that_mention_sender
|
2024-05-30 21:03:13 +09:00
|
|
|
# This queries private mentions from the recipient to the sender up in the thread.
|
|
|
|
# This allows up to 100 messages that do not match in the thread, allowing conversations
|
|
|
|
# involving multiple people.
|
2024-03-07 23:53:37 +09:00
|
|
|
Status.count_by_sql([<<-SQL.squish, id: @notification.target_status.in_reply_to_id, recipient_id: @recipient.id, sender_id: @sender.id, depth_limit: 100])
|
|
|
|
WITH RECURSIVE ancestors(id, in_reply_to_id, mention_id, path, depth) AS (
|
|
|
|
SELECT s.id, s.in_reply_to_id, m.id, ARRAY[s.id], 0
|
|
|
|
FROM statuses s
|
|
|
|
LEFT JOIN mentions m ON m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id
|
|
|
|
WHERE s.id = :id
|
|
|
|
UNION ALL
|
2024-05-30 21:03:13 +09:00
|
|
|
SELECT s.id, s.in_reply_to_id, m.id, ancestors.path || s.id, ancestors.depth + 1
|
|
|
|
FROM ancestors
|
|
|
|
JOIN statuses s ON s.id = ancestors.in_reply_to_id
|
|
|
|
/* early exit if we already have a mention matching our requirements */
|
|
|
|
LEFT JOIN mentions m ON m.silent = FALSE AND m.account_id = :sender_id AND m.status_id = s.id AND s.account_id = :recipient_id
|
|
|
|
WHERE ancestors.mention_id IS NULL AND NOT s.id = ANY(path) AND ancestors.depth < :depth_limit
|
2024-03-07 23:53:37 +09:00
|
|
|
)
|
|
|
|
SELECT COUNT(*)
|
2024-05-30 21:03:13 +09:00
|
|
|
FROM ancestors
|
|
|
|
JOIN statuses s ON s.id = ancestors.id
|
|
|
|
WHERE ancestors.mention_id IS NOT NULL AND s.account_id = :recipient_id AND s.visibility = 3
|
2024-03-07 23:53:37 +09:00
|
|
|
SQL
|
|
|
|
end
|
2017-11-15 05:12:57 +09:00
|
|
|
end
|
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def call(recipient, type, activity)
|
|
|
|
return if recipient.user.nil?
|
|
|
|
|
|
|
|
@recipient = recipient
|
|
|
|
@activity = activity
|
|
|
|
@notification = Notification.new(account: @recipient, type: type, activity: @activity)
|
|
|
|
|
|
|
|
# For certain conditions we don't need to create a notification at all
|
|
|
|
return if dismiss?
|
|
|
|
|
|
|
|
@notification.filtered = filter?
|
2024-06-03 17:35:59 +09:00
|
|
|
@notification.group_key = notification_group_key
|
2024-03-07 23:53:37 +09:00
|
|
|
@notification.save!
|
|
|
|
|
|
|
|
# It's possible the underlying activity has been deleted
|
|
|
|
# between the save call and now
|
|
|
|
return if @notification.activity.nil?
|
|
|
|
|
|
|
|
if @notification.filtered?
|
|
|
|
update_notification_request!
|
|
|
|
else
|
|
|
|
push_notification!
|
|
|
|
push_to_conversation! if direct_message?
|
|
|
|
send_email! if email_needed?
|
|
|
|
end
|
|
|
|
rescue ActiveRecord::RecordInvalid
|
|
|
|
nil
|
2017-11-15 05:12:57 +09:00
|
|
|
end
|
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
private
|
|
|
|
|
2024-06-03 17:35:59 +09:00
|
|
|
def notification_group_key
|
|
|
|
return nil if @notification.filtered || %i(favourite reblog).exclude?(@notification.type)
|
|
|
|
|
|
|
|
type_prefix = "#{@notification.type}-#{@notification.target_status.id}"
|
|
|
|
redis_key = "notif-group/#{@recipient.id}/#{type_prefix}"
|
|
|
|
hour_bucket = @notification.activity.created_at.utc.to_i / 1.hour.to_i
|
|
|
|
|
|
|
|
# Reuse previous group if it does not span too large an amount of time
|
|
|
|
previous_bucket = redis.get(redis_key).to_i
|
|
|
|
hour_bucket = previous_bucket if hour_bucket < previous_bucket + MAXIMUM_GROUP_SPAN_HOURS
|
|
|
|
|
|
|
|
# We do not concern ourselves with race conditions since we use hour buckets
|
2024-07-18 23:36:09 +09:00
|
|
|
redis.set(redis_key, hour_bucket, ex: MAXIMUM_GROUP_SPAN_HOURS)
|
2024-06-03 17:35:59 +09:00
|
|
|
|
|
|
|
"#{type_prefix}-#{hour_bucket}"
|
|
|
|
end
|
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def dismiss?
|
|
|
|
DismissCondition.new(@notification).dismiss?
|
2017-11-15 05:12:57 +09:00
|
|
|
end
|
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def filter?
|
|
|
|
FilterCondition.new(@notification).filter?
|
2016-11-20 08:33:02 +09:00
|
|
|
end
|
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def update_notification_request!
|
|
|
|
return unless @notification.type == :mention
|
|
|
|
|
|
|
|
notification_request = NotificationRequest.find_or_initialize_by(account_id: @recipient.id, from_account_id: @notification.from_account_id)
|
|
|
|
notification_request.last_status_id = @notification.target_status.id
|
|
|
|
notification_request.save
|
Feature conversations muting (#3017)
* Add <ostatus:conversation /> tag to Atom input/output
Only uses ref attribute (not href) because href would be
the alternate link that's always included also.
Creates new conversation for every non-reply status. Carries
over conversation for every reply. Keeps remote URIs verbatim,
generates local URIs on the fly like the rest of them.
* Conversation muting - prevents notifications that reference a conversation
(including replies, favourites, reblogs) from being created. API endpoints
/api/v1/statuses/:id/mute and /api/v1/statuses/:id/unmute
Currently no way to tell when a status/conversation is muted, so the web UI
only has a "disable notifications" button, doesn't work as a toggle
* Display "Dismiss notifications" on all statuses in notifications column, not just own
* Add "muted" as a boolean attribute on statuses JSON
For now always false on contained reblogs, since it's only relevant for
statuses returned from the notifications endpoint, which are not nested
Remove "Disable notifications" from detailed status view, since it's
only relevant in the notifications column
* Up max class length
* Remove pending test for conversation mute
* Add tests, clean up
* Rename to "mute conversation" and "unmute conversation"
* Raise validation error when trying to mute/unmute status without conversation
2017-05-15 10:04:13 +09:00
|
|
|
end
|
|
|
|
|
2022-04-09 01:03:31 +09:00
|
|
|
def push_notification!
|
|
|
|
push_to_streaming_api! if subscribed_to_streaming_api?
|
|
|
|
push_to_web_push_subscriptions!
|
2018-05-11 18:49:12 +09:00
|
|
|
end
|
|
|
|
|
2022-04-09 01:03:31 +09:00
|
|
|
def push_to_streaming_api!
|
|
|
|
redis.publish("timeline:#{@recipient.id}:notifications", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification)))
|
|
|
|
end
|
2018-05-11 18:49:12 +09:00
|
|
|
|
2022-04-09 01:03:31 +09:00
|
|
|
def subscribed_to_streaming_api?
|
|
|
|
redis.exists?("subscribed:timeline:#{@recipient.id}") || redis.exists?("subscribed:timeline:#{@recipient.id}:notifications")
|
2017-07-14 05:15:32 +09:00
|
|
|
end
|
|
|
|
|
2018-10-08 06:44:58 +09:00
|
|
|
def push_to_conversation!
|
|
|
|
AccountConversation.add_status(@recipient, @notification.target_status)
|
|
|
|
end
|
|
|
|
|
2024-03-07 23:53:37 +09:00
|
|
|
def direct_message?
|
|
|
|
@notification.type == :mention && @notification.target_status.direct_visibility?
|
|
|
|
end
|
|
|
|
|
2022-04-09 01:03:31 +09:00
|
|
|
def push_to_web_push_subscriptions!
|
|
|
|
::Web::PushNotificationWorker.push_bulk(web_push_subscriptions.select { |subscription| subscription.pushable?(@notification) }) { |subscription| [subscription.id, @notification.id] }
|
|
|
|
end
|
2017-07-18 23:25:40 +09:00
|
|
|
|
2022-04-09 01:03:31 +09:00
|
|
|
def web_push_subscriptions
|
|
|
|
@web_push_subscriptions ||= ::Web::PushSubscription.where(user_id: @recipient.user.id).to_a
|
|
|
|
end
|
|
|
|
|
|
|
|
def subscribed_to_web_push?
|
|
|
|
web_push_subscriptions.any?
|
2016-11-20 08:33:02 +09:00
|
|
|
end
|
|
|
|
|
2018-10-08 06:44:58 +09:00
|
|
|
def send_email!
|
2023-07-10 10:06:22 +09:00
|
|
|
return unless NotificationMailer.respond_to?(@notification.type)
|
|
|
|
|
|
|
|
NotificationMailer
|
|
|
|
.with(recipient: @recipient, notification: @notification)
|
|
|
|
.public_send(@notification.type)
|
|
|
|
.deliver_later(wait: 2.minutes)
|
2022-04-09 01:03:31 +09:00
|
|
|
end
|
|
|
|
|
|
|
|
def email_needed?
|
|
|
|
(!recipient_online? || always_send_emails?) && send_email_for_notification_type?
|
|
|
|
end
|
|
|
|
|
|
|
|
def recipient_online?
|
|
|
|
subscribed_to_streaming_api? || subscribed_to_web_push?
|
|
|
|
end
|
|
|
|
|
|
|
|
def always_send_emails?
|
|
|
|
@recipient.user.settings.always_send_emails
|
2016-11-20 08:33:02 +09:00
|
|
|
end
|
|
|
|
|
2022-04-09 01:03:31 +09:00
|
|
|
def send_email_for_notification_type?
|
2023-03-30 21:44:00 +09:00
|
|
|
NON_EMAIL_TYPES.exclude?(@notification.type) && @recipient.user.settings["notification_emails.#{@notification.type}"]
|
2016-11-20 08:33:02 +09:00
|
|
|
end
|
|
|
|
end
|