end
def follow
- FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs), with_rate_limit: true)
-
- options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } }
+ follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true)
+ options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } }
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
end
def authorize
AuthorizeFollowService.new.call(account, current_account)
- NotifyService.new.call(current_account, Follow.find_by(account: account, target_account: current_account))
+ NotifyService.new.call(current_account, :follow, Follow.find_by(account: account, target_account: current_account))
render json: account, serializer: REST::RelationshipSerializer, relationships: relationships
end
};
};
-export function followAccount(id, reblogs = true) {
+export function followAccount(id, options = { reblogs: true }) {
return (dispatch, getState) => {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
const locked = getState().getIn(['accounts', id, 'locked'], false);
dispatch(followAccountRequest(id, locked));
- api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
+ api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => {
dispatch(followAccountSuccess(response.data, alreadyFollowing));
}).catch(error => {
dispatch(followAccountFail(error, locked));
let filtered = false;
- if (notification.type === 'mention') {
+ if (['mention', 'status'].includes(notification.type)) {
const dropRegex = filters[0];
const regex = filters[1];
const searchIndex = searchTextFromRawStatus(notification.status);
import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
+import IconButton from 'mastodon/components/icon_button';
import Avatar from 'mastodon/components/avatar';
import { counterRenderer } from 'mastodon/components/common_counter';
import ShortNumber from 'mastodon/components/short_number';
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
+ enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' },
+ disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
- onReport: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
+ onNotifyToggle: PropTypes.func.isRequired,
+ onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired,
let info = [];
let actionBtn = '';
+ let bellBtn = '';
let lockedIcon = '';
let menu = [];
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
}
+ if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
+ bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
+ }
+
if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
actionBtn = '';
}
{!suspended && (
<div className='account__header__tabs__buttons'>
{actionBtn}
+ {bellBtn}
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
</div>
this.props.onReblogToggle(this.props.account);
}
+ handleNotifyToggle = () => {
+ this.props.onNotifyToggle(this.props.account);
+ }
+
handleMute = () => {
this.props.onMute(this.props.account);
}
onMention={this.handleMention}
onDirect={this.handleDirect}
onReblogToggle={this.handleReblogToggle}
+ onNotifyToggle={this.handleNotifyToggle}
onReport={this.handleReport}
onMute={this.handleMute}
onBlockDomain={this.handleBlockDomain}
onReblogToggle (account) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
- dispatch(followAccount(account.get('id'), false));
+ dispatch(followAccount(account.get('id'), { reblogs: false }));
} else {
- dispatch(followAccount(account.get('id'), true));
+ dispatch(followAccount(account.get('id'), { reblogs: true }));
}
},
}
},
+ onNotifyToggle (account) {
+ if (account.getIn(['relationship', 'notifying'])) {
+ dispatch(followAccount(account.get('id'), { notify: false }));
+ } else {
+ dispatch(followAccount(account.get('id'), { notify: true }));
+ }
+ },
+
onReport (account) {
dispatch(initReport(account));
},
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
+ statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
});
export default @injectIntl
>
<Icon id='tasks' fixedWidth />
</button>
+ <button
+ className={selectedFilter === 'status' ? 'active' : ''}
+ onClick={this.onClick('status')}
+ title={intl.formatMessage(tooltips.statuses)}
+ >
+ <Icon id='home' fixedWidth />
+ </button>
<button
className={selectedFilter === 'follow' ? 'active' : ''}
onClick={this.onClick('follow')}
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
+ status: { id: 'notification.status', defaultMessage: '{name} just posted' },
});
const notificationForScreenReader = (intl, message, timestamp) => {
);
}
+ renderStatus (notification, link) {
+ const { intl } = this.props;
+
+ return (
+ <HotKeys handlers={this.getHandlers()}>
+ <div className='notification notification-status focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
+ <div className='notification__message'>
+ <div className='notification__favourite-icon-wrapper'>
+ <Icon id='home' fixedWidth />
+ </div>
+
+ <span title={notification.get('created_at')}>
+ <FormattedMessage id='notification.status' defaultMessage='{name} just posted' values={{ name: link }} />
+ </span>
+ </div>
+
+ <StatusContainer
+ id={notification.get('status')}
+ account={notification.get('account')}
+ muted
+ withDismiss
+ hidden={this.props.hidden}
+ getScrollPosition={this.props.getScrollPosition}
+ updateScrollBottom={this.props.updateScrollBottom}
+ cachedMediaWidth={this.props.cachedMediaWidth}
+ cacheMediaWidth={this.props.cacheMediaWidth}
+ />
+ </div>
+ </HotKeys>
+ );
+ }
+
renderPoll (notification, account) {
const { intl } = this.props;
const ownPoll = me === account.get('id');
return this.renderFavourite(notification, link);
case 'reblog':
return this.renderReblog(notification, link);
+ case 'status':
+ return this.renderStatus(notification, link);
case 'poll':
return this.renderPoll(notification, account);
}
padding: 2px;
}
+ & > .icon-button {
+ margin-right: 8px;
+ }
+
.button {
margin: 0 8px;
}
end
def notify_about_reblog(status)
- NotifyService.new.call(status.reblog.account, status)
+ NotifyService.new.call(status.reblog.account, :reblog, status)
end
def notify_about_mentions(status)
status.active_mentions.includes(:account).each do |mention|
next unless mention.account.local? && audience_includes?(mention.account)
- NotifyService.new.call(mention.account, mention)
+ NotifyService.new.call(mention.account, :mention, mention)
end
end
follow_request = FollowRequest.create!(account: @account, target_account: target_account, uri: @json['id'])
if target_account.locked? || @account.silenced?
- NotifyService.new.call(target_account, follow_request)
+ NotifyService.new.call(target_account, :follow_request, follow_request)
else
AuthorizeFollowService.new.call(@account, target_account)
- NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account))
+ NotifyService.new.call(target_account, :follow, ::Follow.find_by(account: @account, target_account: target_account))
end
end
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
favourite = original_status.favourites.create!(account: @account)
- NotifyService.new.call(original_status.account, favourite)
+ NotifyService.new.call(original_status.account, :favourite, favourite)
end
end
Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping|
mapping[follow.target_account_id] = {
reblogs: follow.show_reblogs?,
+ notify: follow.notify?,
}
end
end
FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping|
mapping[follow_request.target_account_id] = {
reblogs: follow_request.show_reblogs?,
+ notify: follow_request.notify?,
}
end
end
has_many :announcement_mutes, dependent: :destroy
end
- def follow!(other_account, reblogs: nil, uri: nil, rate_limit: false)
- reblogs = true if reblogs.nil?
-
- rel = active_relationships.create_with(show_reblogs: reblogs, uri: uri, rate_limit: rate_limit)
+ def follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false)
+ rel = active_relationships.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit)
.find_or_create_by!(target_account: other_account)
- rel.update!(show_reblogs: reblogs)
+ rel.show_reblogs = reblogs unless reblogs.nil?
+ rel.notify = notify unless notify.nil?
+
+ rel.save! if rel.changed?
+
remove_potential_friendship(other_account)
rel
end
- def request_follow!(other_account, reblogs: nil, uri: nil, rate_limit: false)
- reblogs = true if reblogs.nil?
-
- rel = follow_requests.create_with(show_reblogs: reblogs, uri: uri, rate_limit: rate_limit)
+ def request_follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false)
+ rel = follow_requests.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit)
.find_or_create_by!(target_account: other_account)
- rel.update!(show_reblogs: reblogs)
+ rel.show_reblogs = reblogs unless reblogs.nil?
+ rel.notify = notify unless notify.nil?
+
+ rel.save! if rel.changed?
+
remove_potential_friendship(other_account)
rel
# target_account_id :bigint(8) not null
# show_reblogs :boolean default(TRUE), not null
# uri :string
+# notify :boolean default(FALSE), not null
#
class Follow < ApplicationRecord
end
def revoke_request!
- FollowRequest.create!(account: account, target_account: target_account, show_reblogs: show_reblogs, uri: uri)
+ FollowRequest.create!(account: account, target_account: target_account, show_reblogs: show_reblogs, notify: notify, uri: uri)
destroy!
end
# target_account_id :bigint(8) not null
# show_reblogs :boolean default(TRUE), not null
# uri :string
+# notify :boolean default(FALSE), not null
#
class FollowRequest < ApplicationRecord
validates_with FollowLimitValidator, on: :create
def authorize!
- account.follow!(target_account, reblogs: show_reblogs, uri: uri)
+ account.follow!(target_account, reblogs: show_reblogs, notify: notify, uri: uri)
MergeWorker.perform_async(target_account.id, account.id) if account.local?
destroy!
end
# updated_at :datetime not null
# account_id :bigint(8) not null
# from_account_id :bigint(8) not null
+# type :string
#
class Notification < ApplicationRecord
+ self.inheritance_column = nil
+
include Paginable
include Cacheable
- TYPE_CLASS_MAP = {
- mention: 'Mention',
- reblog: 'Status',
- follow: 'Follow',
- follow_request: 'FollowRequest',
- favourite: 'Favourite',
- poll: 'Poll',
+ LEGACY_TYPE_CLASS_MAP = {
+ 'Mention' => :mention,
+ 'Status' => :reblog,
+ 'Follow' => :follow,
+ 'FollowRequest' => :follow_request,
+ 'Favourite' => :favourite,
+ 'Poll' => :poll,
}.freeze
+ TYPES = %i(
+ mention
+ status
+ reblog
+ follow
+ follow_request
+ favourite
+ poll
+ ).freeze
+
STATUS_INCLUDES = [:account, :application, :preloadable_poll, :media_attachments, :tags, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, :tags, active_mentions: :account]].freeze
belongs_to :account, optional: true
belongs_to :favourite, foreign_type: 'Favourite', foreign_key: 'activity_id', optional: true
belongs_to :poll, foreign_type: 'Poll', foreign_key: 'activity_id', optional: true
- validates :account_id, uniqueness: { scope: [:activity_type, :activity_id] }
- validates :activity_type, inclusion: { in: TYPE_CLASS_MAP.values }
+ validates :type, inclusion: { in: TYPES }
scope :without_suspended, -> { joins(:from_account).merge(Account.without_suspended) }
scope :browserable, ->(exclude_types = [], account_id = nil) {
- types = TYPE_CLASS_MAP.values - activity_types_from_types(exclude_types)
+ types = TYPES - exclude_types.map(&:to_sym)
if account_id.nil?
- where(activity_type: types)
+ where(type: types)
else
- where(activity_type: types, from_account_id: account_id)
+ where(type: types, from_account_id: account_id)
end
}
cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account, follow_request: :account, poll: [status: STATUS_INCLUDES]
def type
- @type ||= TYPE_CLASS_MAP.invert[activity_type].to_sym
+ @type ||= (super || LEGACY_TYPE_CLASS_MAP[activity_type]).to_sym
end
def target_status
case type
+ when :status
+ status
when :reblog
status&.reblog
when :favourite
item.target_status.account = accounts[item.target_status.account_id] if item.target_status
end
end
-
- def activity_types_from_types(types)
- types.map { |type| TYPE_CLASS_MAP[type.to_sym] }.compact
- end
end
after_initialize :set_from_account
end
def status_type?
- [:favourite, :reblog, :mention, :poll].include?(object.type)
+ [:favourite, :reblog, :status, :mention, :poll].include?(object.type)
end
end
# frozen_string_literal: true
class REST::RelationshipSerializer < ActiveModel::Serializer
- attributes :id, :following, :showing_reblogs, :followed_by, :blocking, :blocked_by,
- :muting, :muting_notifications, :requested, :domain_blocking,
- :endorsed, :note
+ attributes :id, :following, :showing_reblogs, :notifying, :followed_by,
+ :blocking, :blocked_by, :muting, :muting_notifications, :requested,
+ :domain_blocking, :endorsed, :note
def id
object.id.to_s
false
end
+ def notifying
+ (instance_options[:relationships].following[object.id] || {})[:notify] ||
+ (instance_options[:relationships].requested[object.id] || {})[:notify] ||
+ false
+ end
+
def followed_by
instance_options[:relationships].followed_by[object.id] || false
end
status = favourite.status
if status.account.local?
- NotifyService.new.call(status.account, favourite)
+ NotifyService.new.call(status.account, :favourite, favourite)
elsif status.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
end
# @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
# @param [Hash] options
# @option [Boolean] :reblogs Whether or not to show reblogs, defaults to true
+ # @option [Boolean] :notify Whether to create notifications about new posts, defaults to false
# @option [Boolean] :bypass_locked
# @option [Boolean] :with_rate_limit
def call(source_account, target_account, options = {})
@source_account = source_account
@target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
- @options = { reblogs: true, bypass_locked: false, with_rate_limit: false }.merge(options)
+ @options = { bypass_locked: false, with_rate_limit: false }.merge(options)
raise ActiveRecord::RecordNotFound if following_not_possible?
raise Mastodon::NotPermittedError if following_not_allowed?
end
def change_follow_options!
- @source_account.follow!(@target_account, reblogs: @options[:reblogs])
+ @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify])
end
def change_follow_request_options!
- @source_account.request_follow!(@target_account, reblogs: @options[:reblogs])
+ @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify])
end
def request_follow!
- follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], rate_limit: @options[:with_rate_limit])
+ follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit])
if @target_account.local?
- LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name)
+ LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, :follow_request)
elsif @target_account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url)
end
end
def direct_follow!
- follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], rate_limit: @options[:with_rate_limit])
+ follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit])
- LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name)
+ LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, :follow)
MergeWorker.perform_async(@target_account.id, @source_account.id)
follow
def import_follows!
parse_import_data!(['Account address'])
- import_relationships!('follow', 'unfollow', @account.following, follow_limit, reblogs: 'Show boosts')
+ import_relationships!('follow', 'unfollow', @account.following, follow_limit, reblogs: { header: 'Show boosts', default: true })
end
def import_blocks!
def import_mutes!
parse_import_data!(['Account address'])
- import_relationships!('mute', 'unmute', @account.muting, ROWS_PROCESSING_LIMIT, notifications: 'Hide notifications')
+ import_relationships!('mute', 'unmute', @account.muting, ROWS_PROCESSING_LIMIT, notifications: { header: 'Hide notifications', default: true })
end
def import_domain_blocks!
def import_relationships!(action, undo_action, overwrite_scope, limit, extra_fields = {})
local_domain_suffix = "@#{Rails.configuration.x.local_domain}"
- items = @data.take(limit).map { |row| [row['Account address']&.strip&.delete_suffix(local_domain_suffix), Hash[extra_fields.map { |key, header| [key, row[header]&.strip] }]] }.reject { |(id, _)| id.blank? }
+ items = @data.take(limit).map { |row| [row['Account address']&.strip&.delete_suffix(local_domain_suffix), Hash[extra_fields.map { |key, field_settings| [key, row[field_settings[:header]]&.strip || field_settings[:default]] }]] }.reject { |(id, _)| id.blank? }
if @import.overwrite?
presence_hash = items.each_with_object({}) { |(id, extra), mapping| mapping[id] = [true, extra] }
# frozen_string_literal: true
class NotifyService < BaseService
- def call(recipient, activity)
+ def call(recipient, type, activity)
@recipient = recipient
@activity = activity
- @notification = Notification.new(account: @recipient, activity: @activity)
+ @notification = Notification.new(account: @recipient, type: type, activity: @activity)
return if recipient.user.nil? || blocked?
FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient)
end
+ def blocked_status?
+ false
+ end
+
def blocked_favourite?
false
end
mentioned_account = mention.account
if mentioned_account.local?
- LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name)
+ LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention)
elsif mentioned_account.activitypub?
ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url)
end
reblogged_status = reblog.reblog
if reblogged_status.account.local?
- LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name)
+ LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, :reblog)
elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
end
private
def check_and_insert
- perform_push unless feed_filtered?
+ return if feed_filtered?
+
+ perform_push
+ perform_notify if notify?
end
def feed_filtered?
end
end
+ def notify?
+ return false if @type != :home || @status.reblog? || (@status.reply? && @status.in_reply_to_account_id != @status.account_id)
+
+ Follow.find_by(account: @follower, target_account: @status.account)&.notify?
+ end
+
def perform_push
case @type
when :home
FeedManager.instance.push_to_list(@list, @status)
end
end
+
+ def perform_notify
+ NotifyService.new.call(@follower, :status, @status)
+ end
end
class LocalNotificationWorker
include Sidekiq::Worker
- def perform(receiver_account_id, activity_id = nil, activity_class_name = nil)
+ def perform(receiver_account_id, activity_id = nil, activity_class_name = nil, type = nil)
if activity_id.nil? && activity_class_name.nil?
activity = Mention.find(receiver_account_id)
receiver = activity.account
activity = activity_class_name.constantize.find(activity_id)
end
- NotifyService.new.call(receiver, activity)
+ NotifyService.new.call(receiver, type || activity_class_name.underscore, activity)
rescue ActiveRecord::RecordNotFound
true
end
# Notify poll owner and remote voters
if poll.local?
ActivityPub::DistributePollUpdateWorker.perform_async(poll.status.id)
- NotifyService.new.call(poll.account, poll)
+ NotifyService.new.call(poll.account, :poll, poll)
end
# Notify local voters
poll.votes.includes(:account).map(&:account).select(&:local?).each do |account|
- NotifyService.new.call(account, poll)
+ NotifyService.new.call(account, :poll, poll)
end
rescue ActiveRecord::RecordNotFound
true
target_account.passive_relationships.where(account: Account.where(domain: nil)).includes(:account).reorder(nil).find_each do |follow|
reblogs = follow.show_reblogs?
+ notify = follow.notify?
# Locally unfollow remote account
follower = follow.account
# Schedule re-follow
begin
- FollowService.new.call(follower, target_account, reblogs: reblogs)
+ FollowService.new.call(follower, target_account, reblogs: reblogs, notify: notify)
rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound, Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError
next
end
old_target_account = Account.find(old_target_account_id)
new_target_account = Account.find(new_target_account_id)
- follow = follower_account.active_relationships.find_by(target_account: old_target_account)
+ follow = follower_account.active_relationships.find_by(target_account: old_target_account)
reblogs = follow&.show_reblogs?
+ notify = follow&.notify?
- FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, bypass_locked: bypass_locked)
+ FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, notify: notify, bypass_locked: bypass_locked)
UnfollowService.new.call(follower_account, old_target_account, skip_unmerge: true)
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
true
--- /dev/null
+require Rails.root.join('lib', 'mastodon', 'migration_helpers')
+
+class AddNotifyToFollows < ActiveRecord::Migration[5.1]
+ include Mastodon::MigrationHelpers
+
+ disable_ddl_transaction!
+
+ def up
+ safety_assured do
+ add_column_with_default :follows, :notify, :boolean, default: false, allow_null: false
+ add_column_with_default :follow_requests, :notify, :boolean, default: false, allow_null: false
+ end
+ end
+
+ def down
+ remove_column :follows, :notify
+ remove_column :follow_requests, :notify
+ end
+end
--- /dev/null
+class AddTypeToNotifications < ActiveRecord::Migration[5.2]
+ def change
+ add_column :notifications, :type, :string
+ end
+end
--- /dev/null
+class AddIndexNotificationsOnType < ActiveRecord::Migration[5.2]
+ disable_ddl_transaction!
+
+ def change
+ add_index :notifications, [:account_id, :id, :type], order: { id: :desc }, algorithm: :concurrently
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+class MigrateNotificationsType < ActiveRecord::Migration[5.2]
+ disable_ddl_transaction!
+
+ TYPES_TO_MIGRATE = {
+ 'Mention' => :mention,
+ 'Status' => :reblog,
+ 'Follow' => :follow,
+ 'FollowRequest' => :follow_request,
+ 'Favourite' => :favourite,
+ 'Poll' => :poll,
+ }.freeze
+
+ def up
+ TYPES_TO_MIGRATE.each_pair do |activity_type, type|
+ Notification.where(activity_type: activity_type, type: nil).in_batches.update_all(type: type)
+ end
+ end
+
+ def down; end
+end
--- /dev/null
+# frozen_string_literal: true
+
+class RemoveIndexNotificationsOnAccountActivity < ActiveRecord::Migration[5.2]
+ disable_ddl_transaction!
+
+ def up
+ remove_index :notifications, name: :account_activity
+ remove_index :notifications, name: :index_notifications_on_account_id_and_id
+ end
+
+ def down
+ add_index :notifications, [:account_id, :activity_id, :activity_type], unique: true, name: 'account_activity', algorithm: :concurrently
+ add_index :notifications, [:account_id, :id], order: { id: :desc }, algorithm: :concurrently
+ end
+end
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2020_09_08_193330) do
+ActiveRecord::Schema.define(version: 2020_09_17_222734) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
t.bigint "target_account_id", null: false
t.boolean "show_reblogs", default: true, null: false
t.string "uri"
+ t.boolean "notify", default: false, null: false
t.index ["account_id", "target_account_id"], name: "index_follow_requests_on_account_id_and_target_account_id", unique: true
end
t.bigint "target_account_id", null: false
t.boolean "show_reblogs", default: true, null: false
t.string "uri"
+ t.boolean "notify", default: false, null: false
t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true
t.index ["target_account_id"], name: "index_follows_on_target_account_id"
end
t.datetime "updated_at", null: false
t.bigint "account_id", null: false
t.bigint "from_account_id", null: false
- t.index ["account_id", "activity_id", "activity_type"], name: "account_activity", unique: true
- t.index ["account_id", "id"], name: "index_notifications_on_account_id_and_id", order: { id: :desc }
+ t.string "type"
+ t.index ["account_id", "id", "type"], name: "index_notifications_on_account_id_and_id_and_type", order: { id: :desc }
t.index ["activity_id", "activity_type"], name: "index_notifications_on_activity_id_and_activity_type"
t.index ["from_account_id"], name: "index_notifications_on_from_account_id"
end
let(:scopes) { 'write:follows' }
let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', locked: locked)).account }
- before do
- post :follow, params: { id: other_account.id }
- end
+ context do
+ before do
+ post :follow, params: { id: other_account.id }
+ end
- context 'with unlocked account' do
- let(:locked) { false }
+ context 'with unlocked account' do
+ let(:locked) { false }
- it 'returns http success' do
- expect(response).to have_http_status(200)
- end
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
- it 'returns JSON with following=true and requested=false' do
- json = body_as_json
+ it 'returns JSON with following=true and requested=false' do
+ json = body_as_json
- expect(json[:following]).to be true
- expect(json[:requested]).to be false
- end
+ expect(json[:following]).to be true
+ expect(json[:requested]).to be false
+ end
+
+ it 'creates a following relation between user and target user' do
+ expect(user.account.following?(other_account)).to be true
+ end
- it 'creates a following relation between user and target user' do
- expect(user.account.following?(other_account)).to be true
+ it_behaves_like 'forbidden for wrong scope', 'read:accounts'
end
- it_behaves_like 'forbidden for wrong scope', 'read:accounts'
+ context 'with locked account' do
+ let(:locked) { true }
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'returns JSON with following=false and requested=true' do
+ json = body_as_json
+
+ expect(json[:following]).to be false
+ expect(json[:requested]).to be true
+ end
+
+ it 'creates a follow request relation between user and target user' do
+ expect(user.account.requested?(other_account)).to be true
+ end
+
+ it_behaves_like 'forbidden for wrong scope', 'read:accounts'
+ end
end
- context 'with locked account' do
- let(:locked) { true }
+ context 'modifying follow options' do
+ let(:locked) { false }
- it 'returns http success' do
- expect(response).to have_http_status(200)
+ before do
+ user.account.follow!(other_account, reblogs: false, notify: false)
end
- it 'returns JSON with following=false and requested=true' do
+ it 'changes reblogs option' do
+ post :follow, params: { id: other_account.id, reblogs: true }
+
json = body_as_json
- expect(json[:following]).to be false
- expect(json[:requested]).to be true
+ expect(json[:following]).to be true
+ expect(json[:showing_reblogs]).to be true
+ expect(json[:notifying]).to be false
end
- it 'creates a follow request relation between user and target user' do
- expect(user.account.requested?(other_account)).to be true
- end
+ it 'changes notify option' do
+ post :follow, params: { id: other_account.id, notify: true }
+
+ json = body_as_json
- it_behaves_like 'forbidden for wrong scope', 'read:accounts'
+ expect(json[:following]).to be true
+ expect(json[:showing_reblogs]).to be false
+ expect(json[:notifying]).to be true
+ end
end
end
context 'account with Follow' do
it 'returns { target_account_id => true }' do
Fabricate(:follow, account: account, target_account: target_account)
- is_expected.to eq(target_account_id => { reblogs: true })
+ is_expected.to eq(target_account_id => { reblogs: true, notify: false })
end
end
let(:target_account) { Fabricate(:account) }
it 'calls Account#follow!, MergeWorker.perform_async, and #destroy!' do
- expect(account).to receive(:follow!).with(target_account, reblogs: true, uri: follow_request.uri)
+ expect(account).to receive(:follow!).with(target_account, reblogs: true, notify: false, uri: follow_request.uri)
expect(MergeWorker).to receive(:perform_async).with(target_account.id, account.id)
expect(follow_request).to receive(:destroy!)
follow_request.authorize!
let(:import) { Import.create(account: account, type: 'following', data: csv) }
it 'follows the listed accounts, including boosts' do
subject.call(import)
+
expect(account.following.count).to eq 1
expect(account.follow_requests.count).to eq 1
expect(Follow.find_by(account: account, target_account: bob).show_reblogs).to be true
RSpec.describe NotifyService, type: :service do
subject do
- -> { described_class.new.call(recipient, activity) }
+ -> { described_class.new.call(recipient, type, activity) }
end
let(:user) { Fabricate(:user) }
let(:recipient) { user.account }
let(:sender) { Fabricate(:account, domain: 'example.com') }
let(:activity) { Fabricate(:follow, account: sender, target_account: recipient) }
+ let(:type) { :follow }
it { is_expected.to change(Notification, :count).by(1) }
context 'for direct messages' do
let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct)) }
+ let(:type) { :mention }
before do
user.settings.interactions = user.settings.interactions.merge('must_be_following_dm' => enabled)
describe 'reblogs' do
let(:status) { Fabricate(:status, account: Fabricate(:account)) }
let(:activity) { Fabricate(:status, account: sender, reblog: status) }
+ let(:type) { :reblog }
it 'shows reblogs by default' do
recipient.follow!(sender)
let(:asshole) { Fabricate(:account, username: 'asshole') }
let(:reply_to) { Fabricate(:status, account: asshole) }
let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, thread: reply_to)) }
+ let(:type) { :mention }
it 'does not notify when conversation is muted' do
recipient.mute_conversation!(activity.status.conversation)
result = subject.perform(account.id)
expect(result).to be_nil
- expect(service).to have_received(:call).with(alice, account, reblogs: true)
- expect(service).to have_received(:call).with(bob, account, reblogs: false)
+ expect(service).to have_received(:call).with(alice, account, reblogs: true, notify: false)
+ expect(service).to have_received(:call).with(bob, account, reblogs: false, notify: false)
end
end
end