Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add notifications of severed relationships #27511

Merged
merged 11 commits into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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