diff --git a/CHANGELOG.md b/CHANGELOG.md index ed1f8c50c1..7982069d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [4.6.5] - 2026-08-06 + +- Fix Collection items being rejected because of incorrect attribute being read (#40052 by @shleeable) +- Fix typo in embedded quote handling code (#40049 by @shleeable) +- Fix account merging worker incorrectly merging `Appeal` and `AccountWarning` records (#39982 by @shleeable) +- Fix off-by-one in handling of updated remote posts allowing up to 5 attachments (#39978 by @shleeable) +- Fix collection items limit not being consistently applied (#39969 by @oneiros) +- Fix oversized profile image crop uploads (#39958 by @sharlayan) +- Fix emoji autocomplete sometimes suggesting emojis for earlier keystrokes (#39947 by @ChaosExAnima) + ## [4.6.4] - 2026-07-27 ### Security diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index ab115ab562..68e3ead65f 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -19,6 +19,8 @@ import { updateTimeline } from './timelines'; let fetchComposeSuggestionsAccountsController; /** @type {AbortController | undefined} */ let fetchComposeSuggestionsTagsController; +/** @type {AbortController | undefined} */ +let searchComposeSuggestionsEmojiController; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; @@ -499,9 +501,8 @@ export function undoUploadCompose(media_id) { } export function clearComposeSuggestions() { - if (fetchComposeSuggestionsAccountsController) { - fetchComposeSuggestionsAccountsController.abort(); - } + fetchComposeSuggestionsAccountsController?.abort(); + searchComposeSuggestionsEmojiController?.abort(); return { type: COMPOSE_SUGGESTIONS_CLEAR, }; @@ -534,12 +535,25 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => { }); }, 200, { leading: true, trailing: true }); -const fetchComposeSuggestionsEmojis = async (dispatch, token) => { - // Right now we are hard-coding the locale to English since the picker search only supports English. - // Once we replace the legacy picker we can remove this and use the actual locale of the user. - const results = await emojiMartSearch(token, 'en', 5); - dispatch(readyComposeSuggestionsEmojis(token, results)); -}; +const fetchComposeSuggestionsEmojis = (dispatch, token) => { + dispatch(clearComposeSuggestions()); + searchComposeSuggestionsEmojiController = new AbortController(); + + void emojiMartSearch({ + token, + // Right now we are hard-coding the locale to English since the picker search only supports English. + // Once we replace the legacy picker we can remove this and use the actual locale of the user. + locale: 'en', + limit: 5, + signal: searchComposeSuggestionsEmojiController.signal, + }).then((results) => { + if (results) { + dispatch(readyComposeSuggestionsEmojis(token, results)); + } + }).finally(() => { + searchComposeSuggestionsEmojiController = undefined; + }); +} const fetchComposeSuggestionsTags = throttle((dispatch, token) => { if (fetchComposeSuggestionsTagsController) { diff --git a/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx b/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx index e63ac427c2..db641ff1c0 100644 --- a/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx @@ -94,12 +94,12 @@ export const ImageUploadModal: FC< setStep('select'); return; } - void calculateCroppedImage(imageSrc, crop).then((blob) => { + void calculateCroppedImage(imageSrc, crop, location).then((blob) => { setImageBlob(blob); setStep('alt'); }); }, - [imageSrc], + [imageSrc, location], ); const dispatch = useAppDispatch(); @@ -418,9 +418,14 @@ const StepAlt: FC<{ async function calculateCroppedImage( imageSrc: string, crop: Area, + location: ImageLocation, ): Promise { const image = await dataUriToImage(imageSrc); - const canvas = new OffscreenCanvas(crop.width, crop.height); + const maxWidth = location === 'avatar' ? 400 : 1500; + const scale = Math.min(1, maxWidth / crop.width); + const width = Math.round(crop.width * scale); + const height = Math.round(crop.height * scale); + const canvas = new OffscreenCanvas(width, height); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Failed to get canvas context'); @@ -437,8 +442,8 @@ async function calculateCroppedImage( crop.height, 0, 0, - crop.width, - crop.height, + width, + height, ); return canvas.convertToBlob(); diff --git a/app/javascript/mastodon/features/emoji/picker.ts b/app/javascript/mastodon/features/emoji/picker.ts index c83df7ba8c..f638c91907 100644 --- a/app/javascript/mastodon/features/emoji/picker.ts +++ b/app/javascript/mastodon/features/emoji/picker.ts @@ -22,34 +22,50 @@ type LegacyEmoji = }; // Replicates the old legacy search function. -export async function emojiMartSearch( - token: string, - locale: string, +export async function emojiMartSearch({ + token, + locale, limit = 5, -): Promise { - const query = token.replace(':', '').trim(); - if (!query.length) { - return []; + signal, +}: { + token: string; + locale: string; + limit?: number; + signal?: AbortSignal; +}): Promise { + try { + const query = token.replace(':', '').trim(); + if (!query.length) { + return []; + } + + const cacheKey = `${query}|${locale}|${limit}`; + const cachedResult = searchCache.get(cacheKey); + if (cachedResult) { + return cachedResult; + } + + const results = await search({ + query, + locale, + limit, + signal, + }); + const legacyResults = results.map((emoji) => + 'shortcode' in emoji + ? ({ id: emoji.shortcode, custom: true } as const) + : { + id: emoji.label.replaceAll(' ', '_').toLowerCase(), + native: emoji.unicode, + }, + ); + searchCache.set(cacheKey, legacyResults); + + return legacyResults; + } catch { + log('aborted search for "%s"', token); + return null; } - - const cacheKey = `${query}|${locale}|${limit}`; - const cachedResult = searchCache.get(cacheKey); - if (cachedResult) { - return cachedResult; - } - - const results = await search({ query, locale, limit }); - const legacyResults = results.map((emoji) => - 'shortcode' in emoji - ? ({ id: emoji.shortcode, custom: true } as const) - : { - id: emoji.label.replaceAll(' ', '_').toLowerCase(), - native: emoji.unicode, - }, - ); - searchCache.set(cacheKey, legacyResults); - - return legacyResults; } const defaultCategories = [ diff --git a/app/javascript/mastodon/features/emoji/search.ts b/app/javascript/mastodon/features/emoji/search.ts index dcdd6be500..140cd4b205 100644 --- a/app/javascript/mastodon/features/emoji/search.ts +++ b/app/javascript/mastodon/features/emoji/search.ts @@ -56,13 +56,18 @@ export async function search({ query: rawQuery, locale: localeString, limit = 0, + signal, }: { query: string; locale: string; limit?: number; + signal?: AbortSignal; }) { + log('searching for "%s"', rawQuery); performance.mark('emoji-search-start'); + signal?.throwIfAborted(); + // Get the locale, and extract tokens from the query. const locale = toSupportedLocale(localeString); const segmenter = localeToSegmenter(locale); @@ -93,6 +98,7 @@ export async function search({ locale, i === queryTokens.length - 1, ); + signal?.throwIfAborted(); const resultMap: ScoreMap = new Map(); const checkedSet = new Set(); @@ -121,6 +127,7 @@ export async function search({ } // Score based on legacy shortcodes, using the higher score if there's a match. + signal?.throwIfAborted(); for (const shortcodeResult of shortcodeResults) { const emoji = resultMap.get(shortcodeResult.hexcode) ?? @@ -183,7 +190,9 @@ export async function search({ // If there are no results, try a cursor-based custom emoji search instead. if (mixedResults.length === 0 || mixedResults.length < limit) { + signal?.throwIfAborted(); const customEmojisFound = await fullCustomSearch(query, allEmojiIds); + signal?.throwIfAborted(); if (customEmojisFound.length > 0) { log( 'cursor search found %d results for "%s"', @@ -373,6 +382,8 @@ async function fullCustomSearch(query: string, existing = new Set()) { // First iterate over chunks of 1,000 custom emoji keys and find any matches. const chunkSize = 1_000; + const maxIterations = 10; + let index = 0; let lastKey: string | null = null; let keys: string[] = []; do { @@ -389,6 +400,10 @@ async function fullCustomSearch(query: string, existing = new Set()) { foundEmojis.add(key); } } + index++; + if (index >= maxIterations) { + break; + } } while (keys.length === chunkSize); // Next get the full emojis for all matches. diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 98888c5915..b37d863e92 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -518,7 +518,7 @@ body > [data-popper-placement] { } .autosuggest-account .account__avatar, -.autosuggest-emoji img { +.autosuggest-emoji .emojione { display: block; width: 24px; height: 24px; diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index d160819e09..da3173ae53 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -404,7 +404,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def fetch_and_verify_quote return if @quote.nil? - embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @json['context']) + embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @json['@context']) ActivityPub::VerifyQuoteService.new.call(@quote, @quote_approval_uri, fetchable_quoted_uri: @quote_uri, prefetched_quoted_object: embedded_quote, request_id: @options[:request_id], depth: @options[:depth]) rescue Mastodon::RecursionLimitExceededError, Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(PROCESSING_DELAY), @quote.id, @quote_uri, { 'request_id' => @options[:request_id], 'approval_uri' => @quote_approval_uri }) diff --git a/app/models/collection.rb b/app/models/collection.rb index 329460b574..0c1535ab39 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -54,7 +54,7 @@ class Collection < ApplicationRecord if: :remote? validates :language, language: { if: :local?, allow_nil: true } validate :tag_is_usable - validate :items_do_not_exceed_limit + validate :items_do_not_exceed_limit, if: :local? validate :user_does_not_exceed_limit, on: :create scope :with_items, -> { includes(:collection_items).merge(CollectionItem.with_accounts) } diff --git a/app/models/concerns/account/merging.rb b/app/models/concerns/account/merging.rb index 03dc621fae..f63fee596e 100644 --- a/app/models/concerns/account/merging.rb +++ b/app/models/concerns/account/merging.rb @@ -16,10 +16,10 @@ module Account::Merging Notification, NotificationPermission, NotificationRequest ], target_account_id: [ - Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin, AccountNote + Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin, AccountNote, + AccountWarning ], reference_account_id: [CanonicalEmailBlock], - account_warning_id: [Appeal], local_account_id: [SeveredRelationship], remote_account_id: [SeveredRelationship], quoted_account_id: [Quote], diff --git a/app/services/activitypub/process_featured_item_service.rb b/app/services/activitypub/process_featured_item_service.rb index fea25ca332..92e50e2031 100644 --- a/app/services/activitypub/process_featured_item_service.rb +++ b/app/services/activitypub/process_featured_item_service.rb @@ -17,9 +17,10 @@ class ActivityPub::ProcessFeaturedItemService return if non_matching_actor_and_approval_uris? return if non_supported_object_type? - with_redis_lock("collection_item:#{@item_json['id']}") do - @collection_item = existing_item || pre_approved_item || new_item + @collection_item = existing_item || pre_approved_item || new_item + return if @collection_item.nil? + with_redis_lock("collection_item:#{@item_json['id']}") do @collection_item.position = position unless position.nil? @collection_item.update!( uri: @item_json['id'], @@ -45,6 +46,8 @@ class ActivityPub::ProcessFeaturedItemService end def new_item + return if @collection.collection_items.count >= ActivityPub::ProcessFeaturedCollectionService::ITEMS_LIMIT + @collection.collection_items.new( created_at: @item_json['published'] ) @@ -67,7 +70,7 @@ class ActivityPub::ProcessFeaturedItemService return false if Account.exists?(uri: @actor_uri) object_json = fetch_resource(@actor_uri, true, raise_on_error: :temporary) - object_json.nil? || (as_array(object_json['fetch']) & ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES).empty? + object_json.nil? || (as_array(object_json['type']) & ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES).empty? end def verify_authorization! diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 954e5f0de0..fdf5e974c8 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -92,7 +92,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService as_array(@json['attachment']).each do |attachment| media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment) - next if media_attachment_parser.remote_url.blank? || @next_media_attachments.size > Status::MEDIA_ATTACHMENTS_LIMIT + next if media_attachment_parser.remote_url.blank? || @next_media_attachments.size >= Status::MEDIA_ATTACHMENTS_LIMIT begin media_attachment = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url } @@ -377,7 +377,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService end def fetch_and_verify_quote!(quote, approval_uri, quote_uri) - embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @activity_json['context']) + embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @activity_json['@context']) ActivityPub::VerifyQuoteService.new.call(quote, approval_uri, fetchable_quoted_uri: quote_uri, prefetched_quoted_object: embedded_quote, request_id: @request_id) rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(PROCESSING_DELAY), quote.id, quote_uri, { 'request_id' => @request_id, 'approval_uri' => approval_uri }) diff --git a/docker-compose.yml b/docker-compose.yml index 803cca8f7e..f959849cfa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,7 +60,7 @@ services: web: # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # build: . - image: ghcr.io/glitch-soc/mastodon:v4.6.4 + image: ghcr.io/glitch-soc/mastodon:v4.6.5 restart: always env_file: .env.production command: bundle exec puma -C config/puma.rb @@ -84,7 +84,7 @@ services: # build: # dockerfile: ./streaming/Dockerfile # context: . - image: ghcr.io/glitch-soc/mastodon-streaming:v4.6.4 + image: ghcr.io/glitch-soc/mastodon-streaming:v4.6.5 restart: always env_file: .env.production command: node ./streaming/index.js @@ -103,7 +103,7 @@ services: sidekiq: # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # build: . - image: ghcr.io/glitch-soc/mastodon:v4.6.4 + image: ghcr.io/glitch-soc/mastodon:v4.6.5 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 17eff7285a..1512701dcb 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 4 + 5 end def default_prerelease diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb index 16322aff9f..68700788b1 100644 --- a/spec/models/collection_spec.rb +++ b/spec/models/collection_spec.rb @@ -53,20 +53,28 @@ RSpec.describe Collection do end context 'when there are more items than allowed' do - subject { Fabricate.build(:collection, collection_items:) } - let(:collection_items) { Fabricate.build_times(described_class::MAX_ITEMS + 1, :collection_item, collection: nil) } - it { is_expected.to_not be_valid } + context 'when collection is local' do + subject { Fabricate.build(:collection, collection_items:) } - context 'when the limit is only exceeded due to `rejected` and `revoked` items' do - let(:collection_items) do - items = Fabricate.build_times(described_class::MAX_ITEMS - 2, :collection_item, collection: nil, state: :accepted) - items << Fabricate.build(:collection_item, collection: nil, state: :pending) - items << Fabricate.build(:collection_item, collection: nil, state: :rejected) - items << Fabricate.build(:collection_item, collection: nil, state: :revoked) - items + it { is_expected.to_not be_valid } + + context 'when the limit is only exceeded due to `rejected` and `revoked` items' do + let(:collection_items) do + items = Fabricate.build_times(described_class::MAX_ITEMS - 2, :collection_item, collection: nil, state: :accepted) + items << Fabricate.build(:collection_item, collection: nil, state: :pending) + items << Fabricate.build(:collection_item, collection: nil, state: :rejected) + items << Fabricate.build(:collection_item, collection: nil, state: :revoked) + items + end + + it { is_expected.to be_valid } end + end + + context 'when collection is remote' do + subject { Fabricate.build(:remote_collection, collection_items:) } it { is_expected.to be_valid } end diff --git a/spec/services/activitypub/process_featured_item_service_spec.rb b/spec/services/activitypub/process_featured_item_service_spec.rb index 3b0f633fe0..706d5c9907 100644 --- a/spec/services/activitypub/process_featured_item_service_spec.rb +++ b/spec/services/activitypub/process_featured_item_service_spec.rb @@ -81,6 +81,17 @@ RSpec.describe ActivityPub::ProcessFeaturedItemService do expect(new_item.position).to eq 1 end end + + context "when the collection's item count is already at the limit" do + before do + stub_const('ActivityPub::ProcessFeaturedCollectionService::ITEMS_LIMIT', 3) + Fabricate.times(3, :collection_item, collection:) + end + + it 'does not create an item' do + expect { subject.call(collection, object) }.to_not change(collection.collection_items, :count) + end + end end context 'when item exists' do