mirror of https://github.com/mastodon/mastodon
Add notification policies and notification requests (#29366)
parent
653ce43abe
commit
50b17f7e10
@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Notifications::PoliciesController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update
|
||||
|
||||
before_action :require_user!
|
||||
before_action :set_policy
|
||||
|
||||
def show
|
||||
render json: @policy, serializer: REST::NotificationPolicySerializer
|
||||
end
|
||||
|
||||
def update
|
||||
@policy.update!(resource_params)
|
||||
render json: @policy, serializer: REST::NotificationPolicySerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_policy
|
||||
@policy = NotificationPolicy.find_or_initialize_by(account: current_account)
|
||||
|
||||
with_read_replica do
|
||||
@policy.summarize!
|
||||
end
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(
|
||||
:filter_not_following,
|
||||
:filter_not_followers,
|
||||
:filter_new_accounts,
|
||||
:filter_private_mentions
|
||||
)
|
||||
end
|
||||
end
|
@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Notifications::RequestsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :index
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index
|
||||
|
||||
before_action :require_user!
|
||||
before_action :set_request, except: :index
|
||||
|
||||
after_action :insert_pagination_headers, only: :index
|
||||
|
||||
def index
|
||||
with_read_replica do
|
||||
@requests = load_requests
|
||||
@relationships = relationships
|
||||
end
|
||||
|
||||
render json: @requests, each_serializer: REST::NotificationRequestSerializer, relationships: @relationships
|
||||
end
|
||||
|
||||
def accept
|
||||
AcceptNotificationRequestService.new.call(@request)
|
||||
render_empty
|
||||
end
|
||||
|
||||
def dismiss
|
||||
@request.update!(dismissed: true)
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_requests
|
||||
requests = NotificationRequest.where(account: current_account).where(dismissed: truthy_param?(:dismissed)).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id(
|
||||
limit_param(DEFAULT_ACCOUNTS_LIMIT),
|
||||
params_slice(:max_id, :since_id, :min_id)
|
||||
)
|
||||
|
||||
NotificationRequest.preload_cache_collection(requests) do |statuses|
|
||||
cache_collection(statuses, Status)
|
||||
end
|
||||
end
|
||||
|
||||
def relationships
|
||||
StatusRelationshipsPresenter.new(@requests.map(&:last_status), current_user&.account_id)
|
||||
end
|
||||
|
||||
def set_request
|
||||
@request = NotificationRequest.where(account: current_account).find(params[:id])
|
||||
end
|
||||
|
||||
def insert_pagination_headers
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
end
|
||||
|
||||
def next_path
|
||||
api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) unless @requests.empty?
|
||||
end
|
||||
|
||||
def prev_path
|
||||
api_v1_notifications_requests_url pagination_params(min_id: pagination_since_id) unless @requests.empty?
|
||||
end
|
||||
|
||||
def pagination_max_id
|
||||
@requests.last.id
|
||||
end
|
||||
|
||||
def pagination_since_id
|
||||
@requests.first.id
|
||||
end
|
||||
|
||||
def pagination_params(core_params)
|
||||
params.slice(:dismissed).permit(:dismissed).merge(core_params)
|
||||
end
|
||||
end
|
@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: notification_permissions
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8) not null
|
||||
# from_account_id :bigint(8) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class NotificationPermission < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :from_account, class_name: 'Account'
|
||||
end
|
@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: notification_policies
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8) not null
|
||||
# filter_not_following :boolean default(FALSE), not null
|
||||
# filter_not_followers :boolean default(FALSE), not null
|
||||
# filter_new_accounts :boolean default(FALSE), not null
|
||||
# filter_private_mentions :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class NotificationPolicy < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
has_many :notification_requests, primary_key: :account_id, foreign_key: :account_id, dependent: nil, inverse_of: false
|
||||
|
||||
attr_reader :pending_requests_count, :pending_notifications_count
|
||||
|
||||
MAX_MEANINGFUL_COUNT = 100
|
||||
|
||||
def summarize!
|
||||
@pending_requests_count = pending_notification_requests.first
|
||||
@pending_notifications_count = pending_notification_requests.last
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pending_notification_requests
|
||||
@pending_notification_requests ||= notification_requests.where(dismissed: false).limit(MAX_MEANINGFUL_COUNT).pick(Arel.sql('count(*), coalesce(sum(notifications_count), 0)::bigint'))
|
||||
end
|
||||
end
|
@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: notification_requests
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8) not null
|
||||
# from_account_id :bigint(8) not null
|
||||
# last_status_id :bigint(8) not null
|
||||
# notifications_count :bigint(8) default(0), not null
|
||||
# dismissed :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class NotificationRequest < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
MAX_MEANINGFUL_COUNT = 100
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :from_account, class_name: 'Account'
|
||||
belongs_to :last_status, class_name: 'Status'
|
||||
|
||||
before_save :prepare_notifications_count
|
||||
|
||||
def self.preload_cache_collection(requests)
|
||||
cached_statuses_by_id = yield(requests.filter_map(&:last_status)).index_by(&:id) # Call cache_collection in block
|
||||
|
||||
requests.each do |request|
|
||||
request.last_status = cached_statuses_by_id[request.last_status_id] unless request.last_status_id.nil?
|
||||
end
|
||||
end
|
||||
|
||||
def reconsider_existence!
|
||||
return if dismissed?
|
||||
|
||||
prepare_notifications_count
|
||||
|
||||
if notifications_count.positive?
|
||||
save
|
||||
else
|
||||
destroy
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_notifications_count
|
||||
self.notifications_count = Notification.where(account: account, from_account: from_account).limit(MAX_MEANINGFUL_COUNT).count
|
||||
end
|
||||
end
|
@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::NotificationPolicySerializer < ActiveModel::Serializer
|
||||
attributes :filter_not_following,
|
||||
:filter_not_followers,
|
||||
:filter_new_accounts,
|
||||
:filter_private_mentions,
|
||||
:summary
|
||||
|
||||
def summary
|
||||
{
|
||||
pending_requests_count: object.pending_requests_count.to_s,
|
||||
pending_notifications_count: object.pending_notifications_count.to_s,
|
||||
}
|
||||
end
|
||||
end
|
@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::NotificationRequestSerializer < ActiveModel::Serializer
|
||||
attributes :id, :created_at, :updated_at, :notifications_count
|
||||
|
||||
belongs_to :from_account, key: :account, serializer: REST::AccountSerializer
|
||||
belongs_to :last_status, serializer: REST::StatusSerializer
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
|
||||
def notifications_count
|
||||
object.notifications_count.to_s
|
||||
end
|
||||
end
|
@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AcceptNotificationRequestService < BaseService
|
||||
def call(request)
|
||||
NotificationPermission.create!(account: request.account, from_account: request.from_account)
|
||||
UnfilterNotificationsWorker.perform_async(request.id)
|
||||
end
|
||||
end
|
@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnfilterNotificationsWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
def perform(notification_request_id)
|
||||
@notification_request = NotificationRequest.find(notification_request_id)
|
||||
|
||||
push_to_conversations!
|
||||
unfilter_notifications!
|
||||
remove_request!
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def push_to_conversations!
|
||||
notifications_with_private_mentions.find_each { |notification| AccountConversation.add_status(@notification_request.account, notification.target_status) }
|
||||
end
|
||||
|
||||
def unfilter_notifications!
|
||||
filtered_notifications.in_batches.update_all(filtered: false)
|
||||
end
|
||||
|
||||
def remove_request!
|
||||
@notification_request.destroy!
|
||||
end
|
||||
|
||||
def filtered_notifications
|
||||
Notification.where(account: @notification_request.account, from_account: @notification_request.from_account, filtered: true)
|
||||
end
|
||||
|
||||
def notifications_with_private_mentions
|
||||
filtered_notifications.joins(mention: :status).merge(Status.where(visibility: :direct)).includes(mention: :status)
|
||||
end
|
||||
end
|
@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddFilteredToNotifications < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :notifications, :filtered, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateNotificationRequests < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :notification_requests do |t|
|
||||
t.references :account, null: false, foreign_key: { on_delete: :cascade }, index: false
|
||||
t.references :from_account, null: false, foreign_key: { to_table: :accounts, on_delete: :cascade }
|
||||
t.references :last_status, null: false, foreign_key: { to_table: :statuses, on_delete: :nullify }
|
||||
t.bigint :notifications_count, null: false, default: 0
|
||||
t.boolean :dismissed, null: false, default: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :notification_requests, [:account_id, :from_account_id], unique: true
|
||||
add_index :notification_requests, [:account_id, :id], where: 'dismissed = false', order: { id: :desc }
|
||||
end
|
||||
end
|
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class NotificationRequestIdsToTimestampIds < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
safety_assured do
|
||||
execute("ALTER TABLE notification_requests ALTER COLUMN id SET DEFAULT timestamp_id('notification_requests')")
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
execute('LOCK notification_requests')
|
||||
execute("SELECT setval('notification_requests_id_seq', (SELECT MAX(id) FROM notification_requests))")
|
||||
execute("ALTER TABLE notification_requests ALTER COLUMN id SET DEFAULT nextval('notification_requests_id_seq')")
|
||||
end
|
||||
end
|
@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateNotificationPermissions < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :notification_permissions do |t|
|
||||
t.references :account, null: false, foreign_key: true
|
||||
t.references :from_account, null: false, foreign_key: { to_table: :accounts }
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateNotificationPolicies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :notification_policies do |t|
|
||||
t.references :account, null: false, foreign_key: true, index: { unique: true }
|
||||
t.boolean :filter_not_following, null: false, default: false
|
||||
t.boolean :filter_not_followers, null: false, default: false
|
||||
t.boolean :filter_new_accounts, null: false, default: false
|
||||
t.boolean :filter_private_mentions, null: false, default: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddFilteredIndexOnNotifications < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :notifications, [:account_id, :id, :type], where: 'filtered = false', order: { id: :desc }, name: 'index_notifications_on_filtered', algorithm: :concurrently
|
||||
end
|
||||
end
|
@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class MigrateInteractionSettingsToPolicy < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
# Dummy classes, to make migration possible across version changes
|
||||
class Account < ApplicationRecord
|
||||
has_one :user, inverse_of: :account
|
||||
has_one :notification_policy, inverse_of: :account
|
||||
end
|
||||
|
||||
class User < ApplicationRecord
|
||||
belongs_to :account
|
||||
end
|
||||
|
||||
class NotificationPolicy < ApplicationRecord
|
||||
belongs_to :account
|
||||
end
|
||||
|
||||
def up
|
||||
User.includes(account: :notification_policy).find_each do |user|
|
||||
deserialized_settings = Oj.load(user.attributes_before_type_cast['settings'])
|
||||
policy = user.account.notification_policy || user.account.build_notification_policy
|
||||
requires_new_policy = false
|
||||
|
||||
if deserialized_settings['interactions.must_be_follower']
|
||||
policy.filter_not_followers = true
|
||||
requires_new_policy = true
|
||||
end
|
||||
|
||||
if deserialized_settings['interactions.must_be_following']
|
||||
policy.filter_not_following = true
|
||||
requires_new_policy = true
|
||||
end
|
||||
|
||||
if deserialized_settings['interactions.must_be_following_dm']
|
||||
policy.filter_private_mentions = true
|
||||
requires_new_policy = true
|
||||
end
|
||||
|
||||
policy.save if requires_new_policy && policy.changed?
|
||||
end
|
||||
end
|
||||
|
||||
def down; end
|
||||
end
|
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:notification_permission) do
|
||||
account
|
||||
from_account { Fabricate.build(:account) }
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:notification_policy) do
|
||||
account
|
||||
filter_not_following false
|
||||
filter_not_followers false
|
||||
filter_new_accounts false
|
||||
filter_private_mentions true
|
||||
end
|
@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:notification_request) do
|
||||
account
|
||||
from_account { Fabricate.build(:account) }
|
||||
last_status { Fabricate.build(:status) }
|
||||
dismissed false
|
||||
end
|
@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe NotificationPolicy do
|
||||
describe '#summarize!' do
|
||||
subject { Fabricate(:notification_policy) }
|
||||
|
||||
let(:sender) { Fabricate(:account) }
|
||||
|
||||
before do
|
||||
Fabricate.times(2, :notification, account: subject.account, activity: Fabricate(:status, account: sender))
|
||||
Fabricate(:notification_request, account: subject.account, from_account: sender)
|
||||
subject.summarize!
|
||||
end
|
||||
|
||||
it 'sets pending_requests_count' do
|
||||
expect(subject.pending_requests_count).to eq 1
|
||||
end
|
||||
|
||||
it 'sets pending_notifications_count' do
|
||||
expect(subject.pending_notifications_count).to eq 2
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe NotificationRequest do
|
||||
describe '#reconsider_existence!' do
|
||||
subject { Fabricate(:notification_request, dismissed: dismissed) }
|
||||
|
||||
let(:dismissed) { false }
|
||||
|
||||
context 'when there are remaining notifications' do
|
||||
before do
|
||||
Fabricate(:notification, account: subject.account, activity: Fabricate(:status, account: subject.from_account))
|
||||
subject.reconsider_existence!
|
||||
end
|
||||
|
||||
it 'leaves request intact' do
|
||||
expect(subject.destroyed?).to be false
|
||||
end
|
||||
|
||||
it 'updates notifications_count' do
|
||||
expect(subject.notifications_count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are no notifications' do
|
||||
before do
|
||||
subject.reconsider_existence!
|
||||
end
|
||||
|
||||
context 'when dismissed' do
|
||||
let(:dismissed) { true }
|
||||
|
||||
it 'leaves request intact' do
|
||||
expect(subject.destroyed?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'removes the request' do
|
||||
expect(subject.destroyed?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue