Skip to content

Commit

Permalink
Add notifications of severed relationships (#27511)
Browse files Browse the repository at this point in the history
  • Loading branch information
ClearlyClaire committed Mar 20, 2024
1 parent 8a1423a commit 44bf7b8
Show file tree
Hide file tree
Showing 39 changed files with 780 additions and 53 deletions.
61 changes: 61 additions & 0 deletions app/controllers/severed_relationships_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import LinkOffIcon from '@/material-icons/400-24px/link_off.svg?react';
import PersonIcon from '@/material-icons/400-24px/person-fill.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
Expand All @@ -26,6 +27,7 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';

import FollowRequestContainer from '../containers/follow_request_container';

import RelationshipsSeveranceEvent from './relationships_severance_event';
import Report from './report';

const messages = defineMessages({
Expand All @@ -36,6 +38,7 @@ const messages = defineMessages({
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
severedRelationships: { id: 'notification.severed_relationships', defaultMessage: 'Relationships with {name} severed' },
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
});
Expand Down Expand Up @@ -358,6 +361,30 @@ class Notification extends ImmutablePureComponent {
);
}

renderRelationshipsSevered (notification) {
const { intl, unread } = this.props;

if (!notification.get('event')) {
return null;
}

return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-severed-relationships focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: notification.getIn(['event', 'target_name']) }), notification.get('created_at'))}>
<div className='notification__message'>
<Icon id='unlink' icon={LinkOffIcon} />

<span title={notification.get('created_at')}>
<FormattedMessage id='notification.severedRelationships' defaultMessage='Relationships with {name} severed' values={{ name: notification.getIn(['event', 'target_name']) }} />
</span>
</div>

<RelationshipsSeveranceEvent event={notification.get('event')} />
</div>
</HotKeys>
);
}

renderAdminSignUp (notification, account, link) {
const { intl, unread } = this.props;

Expand Down Expand Up @@ -429,6 +456,8 @@ class Notification extends ImmutablePureComponent {
return this.renderUpdate(notification, link);
case 'poll':
return this.renderPoll(notification, account);
case 'severed_relationships':
return this.renderRelationshipsSevered(notification);
case 'admin.sign_up':
return this.renderAdminSignUp(notification, account, link);
case 'admin.report':
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions app/javascript/mastodon/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,8 @@
"notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} boosted your post",
"notification.severedRelationships": "Relationships with {name} severed",
"notification.severed_relationships": "Relationships with {name} severed",
"notification.status": "{name} just posted",
"notification.update": "{name} edited a post",
"notification_requests.accept": "Accept",
Expand Down Expand Up @@ -587,6 +589,12 @@
"refresh": "Refresh",
"regeneration_indicator.label": "Loading…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
"relationship_severance_notification.purged_data": "purged by administrators",
"relationship_severance_notification.relationships": "{count, plural, one {# relationship} other {# relationships}}",
"relationship_severance_notification.types.account_suspension": "Account has been suspended",
"relationship_severance_notification.types.domain_block": "Domain has been suspended",
"relationship_severance_notification.types.user_domain_block": "You blocked this domain",
"relationship_severance_notification.view": "View",
"relative_time.days": "{number}d",
"relative_time.full.days": "{number, plural, one {# day} other {# days}} ago",
"relative_time.full.hours": "{number, plural, one {# hour} other {# hours}} ago",
Expand Down
1 change: 1 addition & 0 deletions app/javascript/mastodon/reducers/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const notificationToMap = notification => ImmutableMap({
created_at: notification.created_at,
status: notification.status ? notification.status.id : null,
report: notification.report ? fromJS(notification.report) : null,
event: notification.event ? fromJS(notification.event) : null,
});

const normalizeNotification = (state, notification, usePendingItems) => {
Expand Down
1 change: 1 addition & 0 deletions app/javascript/material-icons/400-24px/link_off-fill.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions app/javascript/material-icons/400-24px/link_off.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions app/models/account_relationship_severance_event.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions app/models/concerns/account/interactions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ def follow_mapping(query, field)
has_many :following, -> { order('follows.id desc') }, through: :active_relationships, source: :target_account
has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account

with_options class_name: 'SeveredRelationship', dependent: :destroy do
has_many :severed_relationships, foreign_key: 'local_account_id', inverse_of: :local_account
has_many :remote_severed_relationships, foreign_key: 'remote_account_id', inverse_of: :remote_account
end

# Account notes
has_many :account_notes, dependent: :destroy

Expand Down
12 changes: 12 additions & 0 deletions app/models/concerns/account/merging.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ def merge_with!(other_account)
record.update_attribute(:account_warning_id, id)
end

SeveredRelationship.where(local_account_id: other_account.id).reorder(nil).find_each do |record|
record.update_attribute(:local_account_id, id)
rescue ActiveRecord::RecordNotUnique
next
end

SeveredRelationship.where(remote_account_id: other_account.id).reorder(nil).find_each do |record|
record.update_attribute(:remote_account_id, id)
rescue ActiveRecord::RecordNotUnique
next
end

# Some follow relationships have moved, so the cache is stale
Rails.cache.delete_matched("followers_hash:#{id}:*")
Rails.cache.delete_matched("relationships:#{id}:*")
Expand Down
9 changes: 9 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ class Notification < ApplicationRecord
update: {
filterable: false,
}.freeze,
severed_relationships: {
filterable: false,
}.freeze,
'admin.sign_up': {
filterable: false,
}.freeze,
Expand Down Expand Up @@ -86,6 +89,7 @@ class Notification < ApplicationRecord
belongs_to :favourite, inverse_of: :notification
belongs_to :poll, inverse_of: false
belongs_to :report, inverse_of: false
belongs_to :relationship_severance_event, inverse_of: false
end

validates :type, inclusion: { in: TYPES }
Expand Down Expand Up @@ -182,6 +186,11 @@ def set_from_account
self.from_account_id = activity&.status&.account_id
when 'Account'
self.from_account_id = activity&.id
when 'AccountRelationshipSeveranceEvent'
# These do not really have an originating account, but this is mandatory
# in the data model, and the recipient's account will by definition
# always exist
self.from_account_id = account_id
end
end

Expand Down
56 changes: 56 additions & 0 deletions app/models/relationship_severance_event.rb
Original file line number Diff line number Diff line change
@@ -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 comments on commit 44bf7b8

Please sign in to comment.