Merge pull request #3434 from ClearlyClaire/glitch-soc/merge-4.4

Merge upstream changes up to d018d9db57 into stable-4.4
This commit is contained in:
Claire
2026-03-09 18:43:12 +01:00
committed by GitHub
11 changed files with 180 additions and 65 deletions

View File

@@ -179,15 +179,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

@@ -23,6 +23,7 @@ export const HoverCardController: React.FC = () => {
const [open, setOpen] = useState(false);
const [accountId, setAccountId] = useState<string | undefined>();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const isUsingTouchRef = useRef(false);
const cardRef = useRef<HTMLDivElement | null>(null);
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
@@ -60,6 +61,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
isUsingTouchRef.current = true;
};
const handleMouseEnter = (e: MouseEvent) => {
const { target } = e;
@@ -69,6 +76,11 @@ export const HoverCardController: React.FC = () => {
return;
}
// Bail out if a touch is active
if (isUsingTouchRef.current) {
return;
}
// We've entered an anchor
if (!isScrolling && isHoverCardAnchor(target)) {
cancelLeaveTimeout();
@@ -127,9 +139,16 @@ export const HoverCardController: React.FC = () => {
};
const handleMouseMove = () => {
if (isUsingTouchRef.current) {
isUsingTouchRef.current = false;
}
delayEnterTimeout(enterDelay);
};
document.body.addEventListener('touchstart', handleTouchStart, {
passive: true,
});
document.body.addEventListener('mouseenter', handleMouseEnter, {
passive: true,
capture: true,
@@ -151,6 +170,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);

View File

@@ -179,15 +179,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

@@ -23,6 +23,7 @@ export const HoverCardController: React.FC = () => {
const [open, setOpen] = useState(false);
const [accountId, setAccountId] = useState<string | undefined>();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const isUsingTouchRef = useRef(false);
const cardRef = useRef<HTMLDivElement | null>(null);
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
@@ -60,6 +61,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
isUsingTouchRef.current = true;
};
const handleMouseEnter = (e: MouseEvent) => {
const { target } = e;
@@ -69,6 +76,11 @@ export const HoverCardController: React.FC = () => {
return;
}
// Bail out if a touch is active
if (isUsingTouchRef.current) {
return;
}
// We've entered an anchor
if (!isScrolling && isHoverCardAnchor(target)) {
cancelLeaveTimeout();
@@ -127,9 +139,16 @@ export const HoverCardController: React.FC = () => {
};
const handleMouseMove = () => {
if (isUsingTouchRef.current) {
isUsingTouchRef.current = false;
}
delayEnterTimeout(enterDelay);
};
document.body.addEventListener('touchstart', handleTouchStart, {
passive: true,
});
document.body.addEventListener('mouseenter', handleMouseEnter, {
passive: true,
capture: true,
@@ -151,6 +170,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);

View File

@@ -411,6 +411,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 = List.where(account: @follower).joins(:list_accounts).where(list_accounts: { account_id: @followee.id }).pluck(:list_id)
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(List.where(account: @source_account).joins(:list_accounts).where(list_accounts: { account_id: @target_account.id }).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

@@ -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