diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 0970fc585e..173506c2b9 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -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(''); diff --git a/app/javascript/flavours/glitch/components/hover_card_controller.tsx b/app/javascript/flavours/glitch/components/hover_card_controller.tsx index d2a636e939..d232b6760c 100644 --- a/app/javascript/flavours/glitch/components/hover_card_controller.tsx +++ b/app/javascript/flavours/glitch/components/hover_card_controller.tsx @@ -23,6 +23,7 @@ export const HoverCardController: React.FC = () => { const [open, setOpen] = useState(false); const [accountId, setAccountId] = useState(); const [anchor, setAnchor] = useState(null); + const isUsingTouchRef = useRef(false); const cardRef = useRef(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); diff --git a/app/javascript/flavours/glitch/entrypoints/public.tsx b/app/javascript/flavours/glitch/entrypoints/public.tsx index 90c4ef8c87..eee276aba6 100644 --- a/app/javascript/flavours/glitch/entrypoints/public.tsx +++ b/app/javascript/flavours/glitch/entrypoints/public.tsx @@ -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(''); diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx index 38c3306f30..43ca37f50f 100644 --- a/app/javascript/mastodon/components/hover_card_controller.tsx +++ b/app/javascript/mastodon/components/hover_card_controller.tsx @@ -23,6 +23,7 @@ export const HoverCardController: React.FC = () => { const [open, setOpen] = useState(false); const [accountId, setAccountId] = useState(); const [anchor, setAnchor] = useState(null); + const isUsingTouchRef = useRef(false); const cardRef = useRef(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); diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 23a2305a1a..4bff0d3e22 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -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 diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index 19a94e77ad..899f586b81 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -4,7 +4,7 @@ class ResolveURLService < BaseService include JsonLdHelper include Authorization - USERNAME_STATUS_RE = %r{/@(?#{Account::USERNAME_RE})/(?[0-9]+)\Z} + USERNAME_STATUS_RE = %r{/@(?#{Account::USERNAME_RE})/(statuses/)?(?[0-9a-zA-Z]+)\Z} def call(url, on_behalf_of: nil) @url = url diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index b3f2cd66f6..e00227f841 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -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 diff --git a/app/views/user_mailer/webauthn_credential_added.text.erb b/app/views/user_mailer/webauthn_credential_added.text.erb index 4319dddbfc..d4482a69bb 100644 --- a/app/views/user_mailer/webauthn_credential_added.text.erb +++ b/app/views/user_mailer/webauthn_credential_added.text.erb @@ -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 %> diff --git a/app/views/user_mailer/webauthn_enabled.text.erb b/app/views/user_mailer/webauthn_enabled.text.erb index d4482a69bb..ca8da30a42 100644 --- a/app/views/user_mailer/webauthn_enabled.text.erb +++ b/app/views/user_mailer/webauthn_enabled.text.erb @@ -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 %> diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index 59f712b9b8..b099b69c88 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -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 } diff --git a/spec/services/unfollow_service_spec.rb b/spec/services/unfollow_service_spec.rb index 6cf24ca5e1..365468e432 100644 --- a/spec/services/unfollow_service_spec.rb +++ b/spec/services/unfollow_service_spec.rb @@ -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