Compare commits

...

21 Commits

Author SHA1 Message Date
Claire
d0348531cd Merge pull request #3442 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to f37dc6c59e into stable-4.5
2026-03-16 18:33:02 +01:00
diondiondion
a97811b056 [Glitch] Prevent hover card from showing unintentionally
Port 316290ba9d to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2026-03-14 11:55:04 +01:00
Claire
f4baed2a69 Merge commit 'f37dc6c59e88aa3a119ecbe76ee9fba480d13daa' into glitch-soc/merge-4.5 2026-03-14 11:54:20 +01:00
Matt Jankowski
f37dc6c59e Normalize current_username on account migration (#38183) 2026-03-13 18:17:18 +01:00
Hugo Gameiro
9171fa49b6 Fix OpenStack Swift Keystone token rate limiting (#38145) 2026-03-13 18:17:18 +01:00
Claire
ac91d30a5a Change HTTP signatures to skip the Accept header (#38132) 2026-03-13 18:17:18 +01:00
diondiondion
dff7d55a6d Prevent hover card from showing unintentionally (#38112) 2026-03-13 18:17:18 +01:00
Claire
c2244cbb67 Merge pull request #3433 from ClearlyClaire/glitch-soc/merge-4.5
Merge upstream changes up to d7059dcf1c into stable-4.5
2026-03-09 18:43:07 +01:00
Claire
436bf0590c [Glitch] Fix “Unblock” and “Unmute” actions being disabled when blocked
Port a3f0a0373d to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2026-03-09 12:58:36 +01:00
Claire
c8a5c2c121 [Glitch] Fix username availability check being wrongly applied on race conditions
Port ea34d35b32 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2026-03-09 12:57:37 +01:00
diondiondion
1033029a6c [Glitch] Prevent hover card from showing on touch devices
Port de4ee8565c to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2026-03-09 12:57:16 +01:00
Claire
c14a62c3af Merge commit 'd7059dcf1c5fb4dcebd80e8033a455917d0a21d1' into glitch-soc/merge-4.5 2026-03-09 12:56:25 +01:00
Claire
d7059dcf1c Fix poll expiration notification being re-triggered on implicit updates (#38078) 2026-03-09 11:39:36 +01:00
Claire
a7bfcf7131 Redirect to short account URLs when requesting HTML for one of the AP endpoints (#38056) 2026-03-09 11:39:36 +01:00
Claire
6fcdc05e43 Add for searching already-known private GtS posts (#38057) 2026-03-09 11:39:36 +01:00
Matt Jankowski
a475f2ba39 Fix incorrect I18n string in webauthn mailers (#38062) 2026-03-09 11:39:36 +01:00
Claire
a3f0a0373d Fix “Unblock” and “Unmute” actions being disabled when blocked (#38075) 2026-03-09 11:39:36 +01:00
Claire
ed521e91e1 Fix username availability check being wrongly applied on race conditions (#37975) 2026-03-09 11:39:36 +01:00
diondiondion
ba22c3f133 Prevent hover card from showing on touch devices (#38039) 2026-03-09 11:39:36 +01:00
Claire
f198ec7c1c Fix existing posts not being removed from lists when a list member is unfollowed (#38048) 2026-03-09 11:39:36 +01:00
Claire
58fed93bae [Glitch] Fix quote-inline fallback being removed even for legacy quotes (#3402)
Port 2a9c7d2b9e to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2026-03-02 12:20:49 +01:00
22 changed files with 308 additions and 99 deletions

View File

@@ -18,6 +18,8 @@ class AccountsController < ApplicationController
respond_to do |format|
format.html do
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in?
redirect_to short_account_path(@account) if account_id_param.present? && username_param.blank?
end
format.rss do

View File

@@ -26,6 +26,8 @@ class StatusesController < ApplicationController
respond_to do |format|
format.html do
expires_in 10.seconds, public: true if current_account.nil?
redirect_to short_account_status_path(@account, @status) if account_id_param.present? && username_param.blank?
end
format.json do

View File

@@ -183,15 +183,25 @@ function loaded() {
({ target }) => {
if (!(target instanceof HTMLInputElement)) return;
if (target.value && target.value.length > 0) {
const checkedUsername = target.value;
if (checkedUsername && checkedUsername.length > 0) {
axios
.get('/api/v1/accounts/lookup', { params: { acct: target.value } })
.get('/api/v1/accounts/lookup', {
params: { acct: checkedUsername },
})
.then(() => {
target.setCustomValidity(formatMessage(messages.usernameTaken));
// Only update the validity if the result is for the currently-typed username
if (checkedUsername === target.value) {
target.setCustomValidity(formatMessage(messages.usernameTaken));
}
return true;
})
.catch(() => {
target.setCustomValidity('');
// Only update the validity if the result is for the currently-typed username
if (checkedUsername === target.value) {
target.setCustomValidity('');
}
});
} else {
target.setCustomValidity('');

View File

@@ -129,6 +129,8 @@ export const FollowButton: React.FC<{
: messages.follow;
let label;
let disabled =
relationship?.blocked_by || account?.suspended || !!account?.moved;
if (!signedIn) {
label = intl.formatMessage(followMessage);
@@ -138,12 +140,16 @@ export const FollowButton: React.FC<{
label = <LoadingIndicator />;
} else if (relationship.muting) {
label = intl.formatMessage(messages.unmute);
disabled = false;
} else if (relationship.following) {
label = intl.formatMessage(messages.unfollow);
disabled = false;
} else if (relationship.blocking) {
label = intl.formatMessage(messages.unblock);
disabled = false;
} else if (relationship.requested) {
label = intl.formatMessage(messages.followRequestCancel);
disabled = false;
} else if (relationship.followed_by && !account?.locked) {
label = intl.formatMessage(messages.followBack);
} else {
@@ -168,11 +174,7 @@ export const FollowButton: React.FC<{
return (
<Button
onClick={handleClick}
disabled={
relationship?.blocked_by ||
(!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved))
}
disabled={disabled}
secondary={following}
compact={compact}
className={classNames(className, { 'button--destructive': following })}

View File

@@ -14,6 +14,10 @@ import { useTimeout } from 'flavours/glitch/hooks/useTimeout';
const offset = [-12, 4] as OffsetValue;
const enterDelay = 750;
const leaveDelay = 150;
// Only open the card if the mouse was moved within this time,
// to avoid triggering the card without intentional mouse movement
// (e.g. when content changed underneath the mouse cursor)
const activeMovementThreshold = 150;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
const isHoverCardAnchor = (element: HTMLElement) =>
@@ -26,6 +30,7 @@ export const HoverCardController: React.FC = () => {
const cardRef = useRef<HTMLDivElement | null>(null);
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
const [setMoveTimeout, cancelMoveTimeout] = useTimeout();
const [setScrollTimeout] = useTimeout();
const location = useLocation();
@@ -42,6 +47,8 @@ export const HoverCardController: React.FC = () => {
useEffect(() => {
let isScrolling = false;
let isUsingTouch = false;
let isActiveMouseMovement = false;
let currentAnchor: HTMLElement | null = null;
let currentTitle: string | null = null;
@@ -60,6 +67,12 @@ export const HoverCardController: React.FC = () => {
setAccountId(undefined);
};
const handleTouchStart = () => {
// Keeping track of touch events to prevent the
// hover card from being displayed on touch devices
isUsingTouch = true;
};
const handleMouseEnter = (e: MouseEvent) => {
const { target } = e;
@@ -69,8 +82,14 @@ export const HoverCardController: React.FC = () => {
return;
}
// Bail out if we're scrolling, a touch is active,
// or if there was no active mouse movement
if (isScrolling || !isActiveMouseMovement || isUsingTouch) {
return;
}
// We've entered an anchor
if (!isScrolling && isHoverCardAnchor(target)) {
if (isHoverCardAnchor(target)) {
cancelLeaveTimeout();
currentAnchor?.removeAttribute('aria-describedby');
@@ -85,10 +104,7 @@ export const HoverCardController: React.FC = () => {
}
// We've entered the hover card
if (
!isScrolling &&
(target === currentAnchor || target === cardRef.current)
) {
if (target === currentAnchor || target === cardRef.current) {
cancelLeaveTimeout();
}
};
@@ -127,9 +143,23 @@ export const HoverCardController: React.FC = () => {
};
const handleMouseMove = () => {
if (isUsingTouch) {
isUsingTouch = false;
}
delayEnterTimeout(enterDelay);
cancelMoveTimeout();
isActiveMouseMovement = true;
setMoveTimeout(() => {
isActiveMouseMovement = false;
}, activeMovementThreshold);
};
document.body.addEventListener('touchstart', handleTouchStart, {
passive: true,
});
document.body.addEventListener('mouseenter', handleMouseEnter, {
passive: true,
capture: true,
@@ -151,6 +181,7 @@ export const HoverCardController: React.FC = () => {
});
return () => {
document.body.removeEventListener('touchstart', handleTouchStart);
document.body.removeEventListener('mouseenter', handleMouseEnter);
document.body.removeEventListener('mousemove', handleMouseMove);
document.body.removeEventListener('mouseleave', handleMouseLeave);
@@ -166,6 +197,8 @@ export const HoverCardController: React.FC = () => {
setOpen,
setAccountId,
setAnchor,
setMoveTimeout,
cancelMoveTimeout,
]);
return (

View File

@@ -177,7 +177,7 @@ class StatusContent extends PureComponent {
{children}
</HandledLink>
);
} else if (element.classList.contains('quote-inline')) {
} else if (element.classList.contains('quote-inline') && this.props.status.get('quote')) {
return null;
}
return undefined;

View File

@@ -183,15 +183,25 @@ function loaded() {
({ target }) => {
if (!(target instanceof HTMLInputElement)) return;
if (target.value && target.value.length > 0) {
const checkedUsername = target.value;
if (checkedUsername && checkedUsername.length > 0) {
axios
.get('/api/v1/accounts/lookup', { params: { acct: target.value } })
.get('/api/v1/accounts/lookup', {
params: { acct: checkedUsername },
})
.then(() => {
target.setCustomValidity(formatMessage(messages.usernameTaken));
// Only update the validity if the result is for the currently-typed username
if (checkedUsername === target.value) {
target.setCustomValidity(formatMessage(messages.usernameTaken));
}
return true;
})
.catch(() => {
target.setCustomValidity('');
// Only update the validity if the result is for the currently-typed username
if (checkedUsername === target.value) {
target.setCustomValidity('');
}
});
} else {
target.setCustomValidity('');

View File

@@ -129,6 +129,8 @@ export const FollowButton: React.FC<{
: messages.follow;
let label;
let disabled =
relationship?.blocked_by || account?.suspended || !!account?.moved;
if (!signedIn) {
label = intl.formatMessage(followMessage);
@@ -138,12 +140,16 @@ export const FollowButton: React.FC<{
label = <LoadingIndicator />;
} else if (relationship.muting) {
label = intl.formatMessage(messages.unmute);
disabled = false;
} else if (relationship.following) {
label = intl.formatMessage(messages.unfollow);
disabled = false;
} else if (relationship.blocking) {
label = intl.formatMessage(messages.unblock);
disabled = false;
} else if (relationship.requested) {
label = intl.formatMessage(messages.followRequestCancel);
disabled = false;
} else if (relationship.followed_by && !account?.locked) {
label = intl.formatMessage(messages.followBack);
} else {
@@ -168,11 +174,7 @@ export const FollowButton: React.FC<{
return (
<Button
onClick={handleClick}
disabled={
relationship?.blocked_by ||
(!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved))
}
disabled={disabled}
secondary={following}
compact={compact}
className={classNames(className, { 'button--destructive': following })}

View File

@@ -14,6 +14,10 @@ import { useTimeout } from 'mastodon/hooks/useTimeout';
const offset = [-12, 4] as OffsetValue;
const enterDelay = 750;
const leaveDelay = 150;
// Only open the card if the mouse was moved within this time,
// to avoid triggering the card without intentional mouse movement
// (e.g. when content changed underneath the mouse cursor)
const activeMovementThreshold = 150;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
const isHoverCardAnchor = (element: HTMLElement) =>
@@ -26,6 +30,7 @@ export const HoverCardController: React.FC = () => {
const cardRef = useRef<HTMLDivElement | null>(null);
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
const [setMoveTimeout, cancelMoveTimeout] = useTimeout();
const [setScrollTimeout] = useTimeout();
const location = useLocation();
@@ -42,6 +47,8 @@ export const HoverCardController: React.FC = () => {
useEffect(() => {
let isScrolling = false;
let isUsingTouch = false;
let isActiveMouseMovement = false;
let currentAnchor: HTMLElement | null = null;
let currentTitle: string | null = null;
@@ -60,6 +67,12 @@ export const HoverCardController: React.FC = () => {
setAccountId(undefined);
};
const handleTouchStart = () => {
// Keeping track of touch events to prevent the
// hover card from being displayed on touch devices
isUsingTouch = true;
};
const handleMouseEnter = (e: MouseEvent) => {
const { target } = e;
@@ -69,8 +82,14 @@ export const HoverCardController: React.FC = () => {
return;
}
// Bail out if we're scrolling, a touch is active,
// or if there was no active mouse movement
if (isScrolling || !isActiveMouseMovement || isUsingTouch) {
return;
}
// We've entered an anchor
if (!isScrolling && isHoverCardAnchor(target)) {
if (isHoverCardAnchor(target)) {
cancelLeaveTimeout();
currentAnchor?.removeAttribute('aria-describedby');
@@ -85,10 +104,7 @@ export const HoverCardController: React.FC = () => {
}
// We've entered the hover card
if (
!isScrolling &&
(target === currentAnchor || target === cardRef.current)
) {
if (target === currentAnchor || target === cardRef.current) {
cancelLeaveTimeout();
}
};
@@ -127,9 +143,23 @@ export const HoverCardController: React.FC = () => {
};
const handleMouseMove = () => {
if (isUsingTouch) {
isUsingTouch = false;
}
delayEnterTimeout(enterDelay);
cancelMoveTimeout();
isActiveMouseMovement = true;
setMoveTimeout(() => {
isActiveMouseMovement = false;
}, activeMovementThreshold);
};
document.body.addEventListener('touchstart', handleTouchStart, {
passive: true,
});
document.body.addEventListener('mouseenter', handleMouseEnter, {
passive: true,
capture: true,
@@ -151,6 +181,7 @@ export const HoverCardController: React.FC = () => {
});
return () => {
document.body.removeEventListener('touchstart', handleTouchStart);
document.body.removeEventListener('mouseenter', handleMouseEnter);
document.body.removeEventListener('mousemove', handleMouseMove);
document.body.removeEventListener('mouseleave', handleMouseLeave);
@@ -166,6 +197,8 @@ export const HoverCardController: React.FC = () => {
setOpen,
setAccountId,
setAnchor,
setMoveTimeout,
cancelMoveTimeout,
]);
return (

View File

@@ -179,7 +179,7 @@ class Request
return
end
signature_value = @signing.sign(signed_headers.without('User-Agent', 'Accept-Encoding'), @verb, Addressable::URI.parse(request.uri))
signature_value = @signing.sign(signed_headers.without('User-Agent', 'Accept-Encoding', 'Accept'), @verb, Addressable::URI.parse(request.uri))
request.headers['Signature'] = signature_value
end

View File

@@ -25,7 +25,10 @@ class AccountMigration < ApplicationRecord
before_validation :set_target_account
before_validation :set_followers_count
attribute :current_username, :string
normalizes :acct, with: ->(acct) { acct.strip.delete_prefix('@') }
normalizes :current_username, with: ->(value) { value.strip.delete_prefix('@') }
validates :acct, presence: true, domain: { acct: true }
validate :validate_migration_cooldown
@@ -33,7 +36,7 @@ class AccountMigration < ApplicationRecord
scope :within_cooldown, -> { where(created_at: cooldown_duration_ago..) }
attr_accessor :current_password, :current_username
attr_accessor :current_password
def self.cooldown_duration_ago
Time.current - COOLDOWN_PERIOD

View File

@@ -410,6 +410,11 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
return unless poll.present? && poll.expires_at.present? && poll.votes.exists?
# If the poll had previously expired, notifications should have already been sent out (or scheduled),
# and re-scheduling them would cause duplicate notifications for people who had already dismissed them
# (see #37948)
return if @previous_expires_at&.past?
PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at
PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
end

View File

@@ -4,7 +4,7 @@ class ResolveURLService < BaseService
include JsonLdHelper
include Authorization
USERNAME_STATUS_RE = %r{/@(?<username>#{Account::USERNAME_RE})/(?<status_id>[0-9]+)\Z}
USERNAME_STATUS_RE = %r{/@(?<username>#{Account::USERNAME_RE})/(statuses/)?(?<status_id>[0-9a-zA-Z]+)\Z}
def call(url, on_behalf_of: nil)
@url = url

View File

@@ -6,16 +6,16 @@ class UnfollowService < BaseService
include Lockable
# Unfollow and notify the remote user
# @param [Account] source_account Where to unfollow from
# @param [Account] target_account Which to unfollow
# @param [Account] follower Where to unfollow from
# @param [Account] followee Which to unfollow
# @param [Hash] options
# @option [Boolean] :skip_unmerge
def call(source_account, target_account, options = {})
@source_account = source_account
@target_account = target_account
@options = options
def call(follower, followee, options = {})
@follower = follower
@followee = followee
@options = options
with_redis_lock("relationship:#{[source_account.id, target_account.id].sort.join(':')}") do
with_redis_lock("relationship:#{[follower.id, followee.id].sort.join(':')}") do
unfollow! || undo_follow_request!
end
end
@@ -23,19 +23,25 @@ class UnfollowService < BaseService
private
def unfollow!
follow = Follow.find_by(account: @source_account, target_account: @target_account)
follow = Follow.find_by(account: @follower, target_account: @followee)
return unless follow
# List members are removed immediately with the follow relationship removal,
# so we need to fetch the list IDs first
list_ids = @follower.owned_lists.with_list_account(@followee).pluck(:list_id) unless @options[:skip_unmerge]
follow.destroy!
create_notification(follow) if !@target_account.local? && @target_account.activitypub?
create_reject_notification(follow) if @target_account.local? && !@source_account.local? && @source_account.activitypub?
if @followee.local? && @follower.remote? && @follower.activitypub?
send_reject_follow(follow)
elsif @followee.remote? && @followee.activitypub?
send_undo_follow(follow)
end
unless @options[:skip_unmerge]
UnmergeWorker.perform_async(@target_account.id, @source_account.id, 'home')
UnmergeWorker.push_bulk(@source_account.owned_lists.with_list_account(@target_account).pluck(:list_id)) do |list_id|
[@target_account.id, list_id, 'list']
UnmergeWorker.perform_async(@followee.id, @follower.id, 'home')
UnmergeWorker.push_bulk(list_ids) do |list_id|
[@followee.id, list_id, 'list']
end
end
@@ -43,22 +49,21 @@ class UnfollowService < BaseService
end
def undo_follow_request!
follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account)
follow_request = FollowRequest.find_by(account: @follower, target_account: @followee)
return unless follow_request
follow_request.destroy!
create_notification(follow_request) unless @target_account.local?
send_undo_follow(follow_request) unless @followee.local?
follow_request
end
def create_notification(follow)
def send_undo_follow(follow)
ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url)
end
def create_reject_notification(follow)
def send_reject_follow(follow)
ActivityPub::DeliveryWorker.perform_async(build_reject_json(follow), follow.target_account_id, follow.account.inbox_url)
end

View File

@@ -1,7 +1,7 @@
<%= t 'devise.mailer.two_factor_enabled.title' %>
<%= t 'devise.mailer.webauthn_credential.added.title' %>
===
<%= t 'devise.mailer.two_factor_enabled.explanation' %>
<%= t 'devise.mailer.webauthn_credential.added.explanation' %>
=> <%= edit_user_registration_url %>

View File

@@ -1,7 +1,7 @@
<%= t 'devise.mailer.webauthn_credential.added.title' %>
<%= t 'devise.mailer.webauthn_enabled.title' %>
===
<%= t 'devise.mailer.webauthn_credential.added.explanation' %>
<%= t 'devise.mailer.webauthn_enabled.explanation' %>
=> <%= edit_user_registration_url %>

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
if ENV['SWIFT_ENABLED'] == 'true'
module PaperclipFogConnectionCache
def connection
@connection ||= begin
key = fog_credentials.hash
Thread.current[:paperclip_fog_connections] ||= {}
Thread.current[:paperclip_fog_connections][key] ||= ::Fog::Storage.new(fog_credentials)
end
end
end
Rails.application.config.after_initialize do
Paperclip::Storage::Fog.prepend(PaperclipFogConnectionCache)
end
end

View File

@@ -7,6 +7,10 @@ RSpec.describe AccountMigration do
describe 'acct' do
it { is_expected.to normalize(:acct).from(' @username@domain ').to('username@domain') }
end
describe 'current_username' do
it { is_expected.to normalize(:current_username).from(' @username ').to('username') }
end
end
describe 'Validations' do

View File

@@ -6,10 +6,20 @@ RSpec.describe 'Accounts show response' do
let(:account) { Fabricate(:account) }
context 'with numeric-based identifiers' do
it 'returns http success' do
get "/ap/users/#{account.id}"
context 'with JSON format' do
it 'returns http success' do
get "/ap/users/#{account.id}", headers: { 'ACCEPT' => 'application/json' }
expect(response).to have_http_status(200)
expect(response).to have_http_status(200)
end
end
context 'with HTML format' do
it 'redirects to success' do
get "/ap/users/#{account.id}", as: 'html'
expect(response).to redirect_to("/@#{account.username}")
end
end
end

View File

@@ -136,6 +136,48 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
end
end
context 'with an implicit update of a poll that has already expired' do
let(:account) { Fabricate(:account, domain: 'example.com') }
let!(:expiration) { 10.days.ago.utc }
let!(:status) do
Fabricate(:status,
text: 'Hello world',
account: account,
poll_attributes: {
options: %w(Foo Bar),
account: account,
multiple: false,
hide_totals: false,
expires_at: expiration,
})
end
let(:payload) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'https://example.com/foo',
type: 'Question',
content: 'Hello world',
endTime: expiration.iso8601,
oneOf: [
poll_option_json('Foo', 4),
poll_option_json('Bar', 3),
],
}
end
before do
travel_to(expiration - 1.day) do
Fabricate(:poll_vote, poll: status.poll)
end
end
it 'does not re-trigger notifications' do
expect { subject.call(status, json, json) }
.to_not enqueue_sidekiq_job(PollExpirationNotifyWorker)
end
end
context 'when the status changes a poll despite being not explicitly marked as updated' do
let(:account) { Fabricate(:account, domain: 'example.com') }
let!(:expiration) { 10.days.from_now.utc }

View File

@@ -5,54 +5,57 @@ require 'rails_helper'
RSpec.describe UnfollowService do
subject { described_class.new }
let(:sender) { Fabricate(:account, username: 'alice') }
let(:follower) { Fabricate(:account) }
let(:followee) { Fabricate(:account) }
describe 'local' do
let(:bob) { Fabricate(:account, username: 'bob') }
before do
follower.follow!(followee)
end
before { sender.follow!(bob) }
shared_examples 'when the followee is in a list' do
let(:list) { Fabricate(:list, account: follower) }
it 'destroys the following relation' do
subject.call(sender, bob)
before do
list.accounts << followee
end
expect(sender)
.to_not be_following(bob)
it 'schedules removal of posts from this user from the list' do
expect { subject.call(follower, followee) }
.to enqueue_sidekiq_job(UnmergeWorker).with(followee.id, list.id, 'list')
end
end
describe 'remote ActivityPub', :inline_jobs do
let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
before do
sender.follow!(bob)
stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
describe 'a local user unfollowing another local user' do
it 'destroys the following relation and unmerge from home' do
expect { subject.call(follower, followee) }
.to change { follower.following?(followee) }.from(true).to(false)
.and enqueue_sidekiq_job(UnmergeWorker).with(followee.id, follower.id, 'home')
end
it 'destroys the following relation and sends unfollow activity' do
subject.call(sender, bob)
expect(sender)
.to_not be_following(bob)
expect(a_request(:post, 'http://example.com/inbox'))
.to have_been_made.once
end
it_behaves_like 'when the followee is in a list'
end
describe 'remote ActivityPub (reverse)', :inline_jobs do
let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
describe 'a local user unfollowing a remote ActivityPub user' do
let(:followee) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
before do
bob.follow!(sender)
stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
it 'destroys the following relation, unmerge from home and sends undo activity' do
expect { subject.call(follower, followee) }
.to change { follower.following?(followee) }.from(true).to(false)
.and enqueue_sidekiq_job(UnmergeWorker).with(followee.id, follower.id, 'home')
.and enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(match_json_values(type: 'Undo'), follower.id, followee.inbox_url)
end
it 'destroys the following relation and sends a reject activity' do
subject.call(bob, sender)
it_behaves_like 'when the followee is in a list'
end
expect(sender)
.to_not be_following(bob)
expect(a_request(:post, 'http://example.com/inbox'))
.to have_been_made.once
describe 'a remote ActivityPub user unfollowing a local user' do
let(:follower) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
it 'destroys the following relation, unmerge from home and sends a reject activity' do
expect { subject.call(follower, followee) }
.to change { follower.following?(followee) }.from(true).to(false)
.and enqueue_sidekiq_job(UnmergeWorker).with(followee.id, follower.id, 'home')
.and enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(match_json_values(type: 'Reject'), followee.id, follower.inbox_url)
end
end
end

View File

@@ -33,20 +33,36 @@ RSpec.describe 'Settings Migrations' do
end
describe 'Creating migrations' do
let(:user) { Fabricate(:user, password: '12345678') }
let(:user) { Fabricate(:user, password:) }
let(:password) { '12345678' }
before { sign_in(user) }
context 'when migration account is changed' do
let(:acct) { Fabricate(:account, also_known_as: [ActivityPub::TagManager.instance.uri_for(user.account)]) }
it 'updates moved to account' do
visit settings_migration_path
context 'when user has encrypted password' do
it 'updates moved to account' do
visit settings_migration_path
expect { fill_in_and_submit }
.to(change { user.account.reload.moved_to_account_id }.to(acct.id))
expect(page)
.to have_content(I18n.t('settings.migrate'))
expect { fill_in_and_submit }
.to(change { user.account.reload.moved_to_account_id }.to(acct.id))
expect(page)
.to have_content(I18n.t('settings.migrate'))
end
end
context 'when user has blank encrypted password value' do
before { user.update! encrypted_password: '' }
it 'updates moved to account using at-username value' do
visit settings_migration_path
expect { fill_in_and_submit_via_username("@#{user.account.username}") }
.to(change { user.account.reload.moved_to_account_id }.to(acct.id))
expect(page)
.to have_content(I18n.t('settings.migrate'))
end
end
end
@@ -92,8 +108,18 @@ RSpec.describe 'Settings Migrations' do
def fill_in_and_submit
fill_in 'account_migration_acct', with: acct.username
fill_in 'account_migration_current_password', with: '12345678'
if block_given?
yield
else
fill_in 'account_migration_current_password', with: password
end
click_on I18n.t('migrations.proceed_with_move')
end
def fill_in_and_submit_via_username(username)
fill_in_and_submit do
fill_in 'account_migration_current_username', with: username
end
end
end
end