mirror of https://github.com/mastodon/mastodon
Add notifications of severed relationships (#27511)
parent
8a1423a474
commit
44bf7b8128
@ -0,0 +1,61 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SeveredRelationshipsController < ApplicationController
|
||||
layout 'admin'
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_body_classes
|
||||
before_action :set_cache_headers
|
||||
|
||||
before_action :set_event, only: [:following, :followers]
|
||||
|
||||
def index
|
||||
@events = AccountRelationshipSeveranceEvent.where(account: current_account)
|
||||
end
|
||||
|
||||
def following
|
||||
respond_to do |format|
|
||||
format.csv { send_data following_data, filename: "following-#{@event.target_name}-#{@event.created_at.to_date.iso8601}.csv" }
|
||||
end
|
||||
end
|
||||
|
||||
def followers
|
||||
respond_to do |format|
|
||||
format.csv { send_data followers_data, filename: "followers-#{@event.target_name}-#{@event.created_at.to_date.iso8601}.csv" }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_event
|
||||
@event = AccountRelationshipSeveranceEvent.find(params[:id])
|
||||
end
|
||||
|
||||
def following_data
|
||||
CSV.generate(headers: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'], write_headers: true) do |csv|
|
||||
@event.severed_relationships.active.where(local_account: current_account).includes(:remote_account).reorder(id: :desc).each do |follow|
|
||||
csv << [acct(follow.target_account), follow.show_reblogs, follow.notify, follow.languages&.join(', ')]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def followers_data
|
||||
CSV.generate(headers: ['Account address'], write_headers: true) do |csv|
|
||||
@event.severed_relationships.passive.where(local_account: current_account).includes(:remote_account).reorder(id: :desc).each do |follow|
|
||||
csv << [acct(follow.account)]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def acct(account)
|
||||
account.local? ? account.local_username_and_domain : account.acct
|
||||
end
|
||||
|
||||
def set_body_classes
|
||||
@body_classes = 'admin'
|
||||
end
|
||||
|
||||
def set_cache_headers
|
||||
response.cache_control.replace(private: true, no_store: true)
|
||||
end
|
||||
end
|
@ -0,0 +1,61 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
|
||||
// This needs to be kept in sync with app/models/relationship_severance_event.rb
|
||||
const messages = defineMessages({
|
||||
account_suspension: { id: 'relationship_severance_notification.types.account_suspension', defaultMessage: 'Account has been suspended' },
|
||||
domain_block: { id: 'relationship_severance_notification.types.domain_block', defaultMessage: 'Domain has been suspended' },
|
||||
user_domain_block: { id: 'relationship_severance_notification.types.user_domain_block', defaultMessage: 'You blocked this domain' },
|
||||
});
|
||||
|
||||
const RelationshipsSeveranceEvent = ({ event, hidden }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (hidden || !event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='notification__report'>
|
||||
<div className='notification__report__details'>
|
||||
<div>
|
||||
<RelativeTimestamp timestamp={event.get('created_at')} short={false} />
|
||||
{' · '}
|
||||
{ event.get('purged') ? (
|
||||
<FormattedMessage
|
||||
id='relationship_severance_notification.purged_data'
|
||||
defaultMessage='purged by administrators'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='relationship_severance_notification.relationships'
|
||||
defaultMessage='{count, plural, one {# relationship} other {# relationships}}'
|
||||
values={{ count: event.get('relationships_count', 0) }}
|
||||
/>
|
||||
)}
|
||||
<br />
|
||||
<strong>{intl.formatMessage(messages[event.get('type')])}</strong>
|
||||
</div>
|
||||
|
||||
<div className='notification__report__actions'>
|
||||
<a href='/severed_relationships' className='button' target='_blank' rel='noopener noreferrer'>
|
||||
<FormattedMessage id='relationship_severance_notification.view' defaultMessage='View' />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
RelationshipsSeveranceEvent.propTypes = {
|
||||
event: ImmutablePropTypes.map.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default RelationshipsSeveranceEvent;
|
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m770-302-60-62q40-11 65-42.5t25-73.5q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 57-29.5 105T770-302ZM634-440l-80-80h86v80h-6ZM792-56 56-792l56-56 736 736-56 56ZM440-280H280q-83 0-141.5-58.5T80-480q0-69 42-123t108-71l74 74h-24q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h65l79 80H320Z"/></svg>
|
After Width: | Height: | Size: 414 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m770-302-60-62q40-11 65-42.5t25-73.5q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 57-29.5 105T770-302ZM634-440l-80-80h86v80h-6ZM792-56 56-792l56-56 736 736-56 56ZM440-280H280q-83 0-141.5-58.5T80-480q0-69 42-123t108-71l74 74h-24q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h65l79 80H320Z"/></svg>
|
After Width: | Height: | Size: 414 B |
@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_relationship_severance_events
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8) not null
|
||||
# relationship_severance_event_id :bigint(8) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class AccountRelationshipSeveranceEvent < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :relationship_severance_event
|
||||
|
||||
delegate :severed_relationships, :type, :target_name, :purged, to: :relationship_severance_event, prefix: false
|
||||
|
||||
before_create :set_relationships_count!
|
||||
|
||||
private
|
||||
|
||||
def set_relationships_count!
|
||||
self.relationships_count = severed_relationships.where(local_account: account).count
|
||||
end
|
||||
end
|
@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: relationship_severance_events
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# type :integer not null
|
||||
# target_name :string not null
|
||||
# purged :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class RelationshipSeveranceEvent < ApplicationRecord
|
||||
self.inheritance_column = nil
|
||||
|
||||
has_many :severed_relationships, inverse_of: :relationship_severance_event, dependent: :delete_all
|
||||
|
||||
enum type: {
|
||||
domain_block: 0,
|
||||
user_domain_block: 1,
|
||||
account_suspension: 2,
|
||||
}
|
||||
|
||||
scope :about_local_account, ->(account) { where(id: SeveredRelationship.about_local_account(account).select(:relationship_severance_event_id)) }
|
||||
|
||||
def import_from_active_follows!(follows)
|
||||
import_from_follows!(follows, true)
|
||||
end
|
||||
|
||||
def import_from_passive_follows!(follows)
|
||||
import_from_follows!(follows, false)
|
||||
end
|
||||
|
||||
def affected_local_accounts
|
||||
Account.where(id: severed_relationships.select(:local_account_id))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def import_from_follows!(follows, active)
|
||||
SeveredRelationship.insert_all(
|
||||
follows.pluck(:account_id, :target_account_id, :show_reblogs, :notify, :languages).map do |account_id, target_account_id, show_reblogs, notify, languages|
|
||||
{
|
||||
local_account_id: active ? account_id : target_account_id,
|
||||
remote_account_id: active ? target_account_id : account_id,
|
||||
show_reblogs: show_reblogs,
|
||||
notify: notify,
|
||||
languages: languages,
|
||||
relationship_severance_event_id: id,
|
||||
direction: active ? :active : :passive,
|
||||
}
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: severed_relationships
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# relationship_severance_event_id :bigint(8) not null
|
||||
# local_account_id :bigint(8) not null
|
||||
# remote_account_id :bigint(8) not null
|
||||
# direction :integer not null
|
||||
# show_reblogs :boolean
|
||||
# notify :boolean
|
||||
# languages :string is an Array
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class SeveredRelationship < ApplicationRecord
|
||||
belongs_to :relationship_severance_event
|
||||
belongs_to :local_account, class_name: 'Account'
|
||||
belongs_to :remote_account, class_name: 'Account'
|
||||
|
||||
enum direction: {
|
||||
passive: 0, # analogous to `local_account.passive_relationships`
|
||||
active: 1, # analogous to `local_account.active_relationships`
|
||||
}
|
||||
|
||||
scope :about_local_account, ->(account) { where(local_account: account) }
|
||||
|
||||
scope :active, -> { where(direction: :active) }
|
||||
scope :passive, -> { where(direction: :passive) }
|
||||
|
||||
def account
|
||||
active? ? local_account : remote_account
|
||||
end
|
||||
|
||||
def target_account
|
||||
active? ? remote_account : local_account
|
||||
end
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::AccountRelationshipSeveranceEventSerializer < ActiveModel::Serializer
|
||||
attributes :id, :type, :purged, :target_name, :created_at
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
end
|
@ -0,0 +1,34 @@
|
||||
- content_for :page_title do
|
||||
= t('settings.severed_relationships')
|
||||
|
||||
%p.muted-hint= t('severed_relationships.preamble')
|
||||
|
||||
- unless @events.empty?
|
||||
.table-wrapper
|
||||
%table.table
|
||||
%thead
|
||||
%tr
|
||||
%th= t('exports.archive_takeout.date')
|
||||
%th= t('severed_relationships.type')
|
||||
%th= t('severed_relationships.lost_follows')
|
||||
%th= t('severed_relationships.lost_followers')
|
||||
%tbody
|
||||
- @events.each do |event|
|
||||
%tr
|
||||
%td= l event.created_at
|
||||
%td= t("severed_relationships.event_type.#{event.type}", target_name: event.target_name)
|
||||
- if event.purged?
|
||||
%td{ rowspan: 2 }= t('severed_relationships.purged')
|
||||
- else
|
||||
%td
|
||||
- count = event.severed_relationships.active.where(local_account: current_account).count
|
||||
- if count.zero?
|
||||
= t('generic.none')
|
||||
- else
|
||||
= table_link_to 'download', t('severed_relationships.download', count: count), following_severed_relationship_path(event, format: :csv)
|
||||
%td
|
||||
- count = event.severed_relationships.passive.where(local_account: current_account).count
|
||||
- if count.zero?
|
||||
= t('generic.none')
|
||||
- else
|
||||
= table_link_to 'download', t('severed_relationships.download', count: count), followers_severed_relationship_path(event, format: :csv)
|
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateRelationshipSeveranceEvents < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :relationship_severance_events do |t|
|
||||
t.integer :type, null: false
|
||||
t.string :target_name, null: false
|
||||
t.boolean :purged, null: false, default: false
|
||||
|
||||
t.timestamps
|
||||
|
||||
t.index [:type, :target_name]
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateSeveredRelationships < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :severed_relationships do |t|
|
||||
# No need to have an index on this foreign key as it is covered by `index_severed_relationships_on_unique_tuples`
|
||||
t.references :relationship_severance_event, null: false, foreign_key: { on_delete: :cascade }, index: false
|
||||
|
||||
# No need to have an index on this foregin key as it is covered by `index_severed_relationships_on_local_account_and_event`
|
||||
t.references :local_account, null: false, foreign_key: { to_table: :accounts, on_delete: :cascade }, index: false
|
||||
t.references :remote_account, null: false, foreign_key: { to_table: :accounts, on_delete: :cascade }
|
||||
|
||||
# Used to describe whether `local_account` is the active (follower) or passive (followed) part of the relationship
|
||||
t.integer :direction, null: false
|
||||
|
||||
# Those attributes are carried over from the `follows` table
|
||||
t.boolean :show_reblogs
|
||||
t.boolean :notify
|
||||
t.string :languages, array: true
|
||||
|
||||
t.timestamps
|
||||
|
||||
t.index [:relationship_severance_event_id, :local_account_id, :direction, :remote_account_id], name: 'index_severed_relationships_on_unique_tuples', unique: true
|
||||
t.index [:local_account_id, :relationship_severance_event_id], name: 'index_severed_relationships_on_local_account_and_event'
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateAccountRelationshipSeveranceEvents < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :account_relationship_severance_events do |t|
|
||||
t.belongs_to :account, foreign_key: { on_delete: :cascade }, null: false, index: { unique: true }
|
||||
t.belongs_to :relationship_severance_event, foreign_key: { on_delete: :cascade }, null: false, index: { unique: true }
|
||||
|
||||
t.integer :relationships_count, default: 0, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:account_relationship_severance_event) do
|
||||
account
|
||||
relationship_severance_event
|
||||
end
|
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:relationship_severance_event) do
|
||||
type { :domain_block }
|
||||
target_name { 'example.com' }
|
||||
end
|
@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:severed_relationship) do
|
||||
local_account { Fabricate.build(:account) }
|
||||
remote_account { Fabricate.build(:account) }
|
||||
relationship_severance_event { Fabricate.build(:relationship_severance_event) }
|
||||
direction { :active }
|
||||
end
|
@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe RelationshipSeveranceEvent do
|
||||
let(:local_account) { Fabricate(:account) }
|
||||
let(:remote_account) { Fabricate(:account, domain: 'example.com') }
|
||||
let(:event) { Fabricate(:relationship_severance_event) }
|
||||
|
||||
describe '#import_from_active_follows!' do
|
||||
before do
|
||||
local_account.follow!(remote_account)
|
||||
end
|
||||
|
||||
it 'imports the follow relationships with the expected direction' do
|
||||
event.import_from_active_follows!(local_account.active_relationships)
|
||||
|
||||
relationships = event.severed_relationships.to_a
|
||||
expect(relationships.size).to eq 1
|
||||
expect(relationships[0].account).to eq local_account
|
||||
expect(relationships[0].target_account).to eq remote_account
|
||||
end
|
||||
end
|
||||
|
||||
describe '#import_from_passive_follows!' do
|
||||
before do
|
||||
remote_account.follow!(local_account)
|
||||
end
|
||||
|
||||
it 'imports the follow relationships with the expected direction' do
|
||||
event.import_from_passive_follows!(local_account.passive_relationships)
|
||||
|
||||
relationships = event.severed_relationships.to_a
|
||||
expect(relationships.size).to eq 1
|
||||
expect(relationships[0].account).to eq remote_account
|
||||
expect(relationships[0].target_account).to eq local_account
|
||||
end
|
||||
end
|
||||
|
||||
describe '#affected_local_accounts' do
|
||||
before do
|
||||
event.severed_relationships.create!(local_account: local_account, remote_account: remote_account, direction: :active)
|
||||
end
|
||||
|
||||
it 'correctly lists local accounts' do
|
||||
expect(event.affected_local_accounts.to_a).to contain_exactly(local_account)
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SeveredRelationship do
|
||||
let(:local_account) { Fabricate(:account) }
|
||||
let(:remote_account) { Fabricate(:account, domain: 'example.com') }
|
||||
let(:event) { Fabricate(:relationship_severance_event) }
|
||||
|
||||
describe '#account' do
|
||||
context 'when the local account is the follower' do
|
||||
let(:severed_relationship) { Fabricate(:severed_relationship, relationship_severance_event: event, local_account: local_account, remote_account: remote_account, direction: :active) }
|
||||
|
||||
it 'returns the local account' do
|
||||
expect(severed_relationship.account).to eq local_account
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the local account is being followed' do
|
||||
let(:severed_relationship) { Fabricate(:severed_relationship, relationship_severance_event: event, local_account: local_account, remote_account: remote_account, direction: :passive) }
|
||||
|
||||
it 'returns the remote account' do
|
||||
expect(severed_relationship.account).to eq remote_account
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#target_account' do
|
||||
context 'when the local account is the follower' do
|
||||
let(:severed_relationship) { Fabricate(:severed_relationship, relationship_severance_event: event, local_account: local_account, remote_account: remote_account, direction: :active) }
|
||||
|
||||
it 'returns the remote account' do
|
||||
expect(severed_relationship.target_account).to eq remote_account
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the local account is being followed' do
|
||||
let(:severed_relationship) { Fabricate(:severed_relationship, relationship_severance_event: event, local_account: local_account, remote_account: remote_account, direction: :passive) }
|
||||
|
||||
it 'returns the local account' do
|
||||
expect(severed_relationship.target_account).to eq local_account
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Severed relationships page' do
|
||||
include RoutingHelper
|
||||
|
||||
describe 'GET severed_relationships#index' do
|
||||
let(:user) { Fabricate(:user) }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
|
||||
Fabricate(:severed_relationship, local_account: user.account)
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
get severed_relationships_path
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue