From 2ab060be0f82007f44689e9ea6eaab46b836d1fa Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 17 Jul 2026 12:41:59 +0200 Subject: [PATCH 01/12] Fix suspended accounts not being removed from follow request count in `/api/v1/accounts/verify_credentials` (#39858) --- app/serializers/rest/credential_account_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/serializers/rest/credential_account_serializer.rb b/app/serializers/rest/credential_account_serializer.rb index 0bbb7792cd..2828f60fbf 100644 --- a/app/serializers/rest/credential_account_serializer.rb +++ b/app/serializers/rest/credential_account_serializer.rb @@ -14,7 +14,7 @@ class REST::CredentialAccountSerializer < REST::AccountSerializer language: user.setting_default_language, note: object.note, fields: object.fields.map(&:to_h), - follow_requests_count: FollowRequest.where(target_account: object).limit(40).count, + follow_requests_count: Account.without_suspended.joins(:follow_requests).where(follow_requests: { target_account: object }).limit(40).count, hide_collections: object.hide_collections, discoverable: object.discoverable, indexable: object.indexable, From 89fa67c0e3586f2331b4348de515fa4a9adc88f8 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 17 Jul 2026 11:22:43 +0200 Subject: [PATCH 02/12] Fix swapped order of "accept/reject" actions in follow requests (#39862) --- .../components/notification_follow_request.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx index 5f61f2cd78..1ca24d0427 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_follow_request.tsx @@ -61,18 +61,18 @@ export const NotificationFollowRequest: React.FC<{ const actions = ( <> - + ); From ed8fbafb56359a6afb77337335e3dc8e035bcef2 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 25 Jun 2026 17:49:27 +0200 Subject: [PATCH 03/12] Change autosuggestions to include second word in web UI (#39622) --- .../mastodon/components/autosuggest/utils.ts | 29 +++++++++++++++++++ .../mastodon/components/autosuggest_input.jsx | 26 +---------------- .../components/autosuggest_textarea.jsx | 28 ++---------------- 3 files changed, 32 insertions(+), 51 deletions(-) create mode 100644 app/javascript/mastodon/components/autosuggest/utils.ts diff --git a/app/javascript/mastodon/components/autosuggest/utils.ts b/app/javascript/mastodon/components/autosuggest/utils.ts new file mode 100644 index 0000000000..bef96374bf --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest/utils.ts @@ -0,0 +1,29 @@ +export const textAtCursorMatchesToken = ( + str: string, + caretPosition: number, + searchTokens: string[], +) => { + let word: string; + + const regex = new RegExp(`[${searchTokens.join('')}\\w]+(\\s[\\w]+)?$`); + const left = str.slice(0, caretPosition).search(regex); + const right = str.slice(caretPosition).search(/\s/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + word = word.trim(); + + if (word.length < 3 || (word[0] && !searchTokens.includes(word[0]))) { + return [null, null]; + } + + if (word.length > 0) { + return [left + 1, word]; + } else { + return [null, null]; + } +}; diff --git a/app/javascript/mastodon/components/autosuggest_input.jsx b/app/javascript/mastodon/components/autosuggest_input.jsx index 4a3b7d9040..b3062ec383 100644 --- a/app/javascript/mastodon/components/autosuggest_input.jsx +++ b/app/javascript/mastodon/components/autosuggest_input.jsx @@ -12,31 +12,7 @@ import AutosuggestAccountContainer from '../features/compose/containers/autosugg import { AutosuggestEmoji } from './autosuggest_emoji'; import { AutosuggestHashtag } from './autosuggest_hashtag'; import { LocalCustomEmojiProvider } from './emoji/context'; - -const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { - let word; - - let left = str.slice(0, caretPosition).search(/\S+$/); - let right = str.slice(caretPosition).search(/\s/); - - if (right < 0) { - word = str.slice(left); - } else { - word = str.slice(left, right + caretPosition); - } - - if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) { - return [null, null]; - } - - word = word.trim(); - - if (word.length > 0) { - return [left + 1, word]; - } else { - return [null, null]; - } -}; +import { textAtCursorMatchesToken } from './autosuggest/utils'; export default class AutosuggestInput extends ImmutablePureComponent { diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx index fb99f62a73..333c195447 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.jsx +++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx @@ -13,31 +13,7 @@ import AutosuggestAccountContainer from '../features/compose/containers/autosugg import { AutosuggestEmoji } from './autosuggest_emoji'; import { AutosuggestHashtag } from './autosuggest_hashtag'; import { LocalCustomEmojiProvider } from './emoji/context'; - -const textAtCursorMatchesToken = (str, caretPosition) => { - let word; - - let left = str.slice(0, caretPosition).search(/\S+$/); - let right = str.slice(caretPosition).search(/\s/); - - if (right < 0) { - word = str.slice(left); - } else { - word = str.slice(left, right + caretPosition); - } - - if (!word || word.trim().length < 3 || ['@', '@', ':', '#', '#'].indexOf(word[0]) === -1) { - return [null, null]; - } - - word = word.trim(); - - if (word.length > 0) { - return [left + 1, word]; - } else { - return [null, null]; - } -}; +import { textAtCursorMatchesToken } from './autosuggest/utils'; const AutosuggestTextarea = forwardRef(({ value, @@ -64,7 +40,7 @@ const AutosuggestTextarea = forwardRef(({ const tokenStartRef = useRef(0); const handleChange = useCallback((e) => { - const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, ['@', '@', ':', '#', '#']); if (token !== null && lastTokenRef.current !== token) { tokenStartRef.current = tokenStart; From dba4b37b8ff8062cba22dbd9a6870ec898e9c522 Mon Sep 17 00:00:00 2001 From: zunda Date: Thu, 2 Jul 2026 02:50:12 -1000 Subject: [PATCH 04/12] Accept non-Latin characters to suggest hash tag (#39696) --- .../components/autosuggest/utils.test.ts | 29 +++++++++++++++++++ .../mastodon/components/autosuggest/utils.ts | 7 ++++- app/javascript/mastodon/utils/hashtags.ts | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 app/javascript/mastodon/components/autosuggest/utils.test.ts diff --git a/app/javascript/mastodon/components/autosuggest/utils.test.ts b/app/javascript/mastodon/components/autosuggest/utils.test.ts new file mode 100644 index 0000000000..3e92e2b154 --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest/utils.test.ts @@ -0,0 +1,29 @@ +import { textAtCursorMatchesToken } from './utils'; + +describe('textAtCursorMatchesToken', () => { + test.concurrent.for([ + [ + ['#hashtag', 7, ['#']], + [1, '#hashtag'], + ], + [ + ['#hash tag', 8, ['#']], + [1, '#hash tag'], + ], + [ + ['#ハッシュタグ', 6, ['#']], + [1, '#ハッシュタグ'], + ], + [ + ['#ハッシュ タグ', 7, ['#']], + [1, '#ハッシュ タグ'], + ], + ] as const)( + 'textAtCursorMatchesToken(%s) is %o', + ([input, expected], { expect }) => { + expect( + textAtCursorMatchesToken(input[0], input[1], Array.from(input[2])), + ).toStrictEqual(expected); + }, + ); +}); diff --git a/app/javascript/mastodon/components/autosuggest/utils.ts b/app/javascript/mastodon/components/autosuggest/utils.ts index bef96374bf..52268bf3cd 100644 --- a/app/javascript/mastodon/components/autosuggest/utils.ts +++ b/app/javascript/mastodon/components/autosuggest/utils.ts @@ -1,3 +1,5 @@ +import { WORD } from '../../utils/hashtags'; + export const textAtCursorMatchesToken = ( str: string, caretPosition: number, @@ -5,7 +7,10 @@ export const textAtCursorMatchesToken = ( ) => { let word: string; - const regex = new RegExp(`[${searchTokens.join('')}\\w]+(\\s[\\w]+)?$`); + const regex = new RegExp( + `[${searchTokens.join('')}${WORD}]+(\\s[${WORD}]+)?$`, + 'iu', + ); const left = str.slice(0, caretPosition).search(regex); const right = str.slice(caretPosition).search(/\s/); diff --git a/app/javascript/mastodon/utils/hashtags.ts b/app/javascript/mastodon/utils/hashtags.ts index 963ca48369..d136f1ff60 100644 --- a/app/javascript/mastodon/utils/hashtags.ts +++ b/app/javascript/mastodon/utils/hashtags.ts @@ -1,6 +1,6 @@ const HASHTAG_SEPARATORS = '_\\u00b7\\u200c'; const ALPHA = '\\p{L}\\p{M}'; -const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}'; +export const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}'; const buildHashtagPatternRegex = () => { try { From c573b26b38a14de20c6a963306395967f3c3c347 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 20 Jul 2026 11:29:37 +0200 Subject: [PATCH 05/12] Improve emoji search (#39815) --- app/javascript/mastodon/actions/compose.js | 2 +- .../components/autosuggest/utils.test.ts | 8 + .../mastodon/components/autosuggest/utils.ts | 2 +- .../mastodon/features/emoji/database.test.ts | 108 +---- .../mastodon/features/emoji/database.ts | 246 ++--------- .../mastodon/features/emoji/db-schema.ts | 2 +- .../mastodon/features/emoji/normalize.ts | 2 +- .../mastodon/features/emoji/picker.ts | 2 +- .../mastodon/features/emoji/search.test.ts | 188 ++++++++ .../mastodon/features/emoji/search.ts | 407 ++++++++++++++++++ app/javascript/testing/factories.ts | 3 +- package.json | 1 + yarn.lock | 10 + 13 files changed, 652 insertions(+), 329 deletions(-) create mode 100644 app/javascript/mastodon/features/emoji/search.test.ts create mode 100644 app/javascript/mastodon/features/emoji/search.ts diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 1e533b3279..987680a164 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -570,7 +570,7 @@ const fetchComposeSuggestionsTags = throttle((dispatch, token) => { }, 200, { leading: true, trailing: true }); export function fetchComposeSuggestions(token) { - return (dispatch, getState) => { + return (dispatch) => { switch (token[0]) { case ':': void fetchComposeSuggestionsEmojis(dispatch, token); diff --git a/app/javascript/mastodon/components/autosuggest/utils.test.ts b/app/javascript/mastodon/components/autosuggest/utils.test.ts index 3e92e2b154..79b8b767b8 100644 --- a/app/javascript/mastodon/components/autosuggest/utils.test.ts +++ b/app/javascript/mastodon/components/autosuggest/utils.test.ts @@ -10,6 +10,14 @@ describe('textAtCursorMatchesToken', () => { ['#hash tag', 8, ['#']], [1, '#hash tag'], ], + [ + [':+1', 2, [':']], + [1, ':+1'], + ], + [ + [':-1', 2, [':']], + [1, ':-1'], + ], [ ['#ハッシュタグ', 6, ['#']], [1, '#ハッシュタグ'], diff --git a/app/javascript/mastodon/components/autosuggest/utils.ts b/app/javascript/mastodon/components/autosuggest/utils.ts index 52268bf3cd..3e02114a97 100644 --- a/app/javascript/mastodon/components/autosuggest/utils.ts +++ b/app/javascript/mastodon/components/autosuggest/utils.ts @@ -8,7 +8,7 @@ export const textAtCursorMatchesToken = ( let word: string; const regex = new RegExp( - `[${searchTokens.join('')}${WORD}]+(\\s[${WORD}]+)?$`, + `[${searchTokens.join('')}${WORD}+-]+(\\s[${WORD}]+)?$`, 'iu', ); const left = str.slice(0, caretPosition).search(regex); diff --git a/app/javascript/mastodon/features/emoji/database.test.ts b/app/javascript/mastodon/features/emoji/database.test.ts index f51d1af30c..27f958f0a2 100644 --- a/app/javascript/mastodon/features/emoji/database.test.ts +++ b/app/javascript/mastodon/features/emoji/database.test.ts @@ -5,7 +5,6 @@ import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories'; import { putEmojiData, - search, loadEmojiByHexcode, testClear, testGet, @@ -15,10 +14,11 @@ import { } from './database'; function rawEmojiFactory(data: Partial = {}): CompactEmoji { + const factory = unicodeEmojiFactory(); return { - ...unicodeEmojiFactory(), - tags: ['test', 'emoji'], + ...factory, ...data, + tags: data.tags ?? factory.tokens, }; } @@ -32,108 +32,6 @@ describe('emoji database', () => { indexedDB = new IDBFactory(); }); - describe('search', () => { - beforeEach(async () => { - await putEmojiData([], 'en'); - }); - - test('test no query tokens', async () => { - await putEmojiData([rawEmojiFactory()], 'en'); - await expect(search({ query: ' ', locale: 'en' })).resolves.toEqual([]); - }); - - test('unicode results', async () => { - await putEmojiData( - [ - rawEmojiFactory({ - hexcode: 'unicode_hex', - label: 'Party Popper', - shortcodes: ['party_popper'], - unicode: '🎉', - }), - ], - 'en', - ); - - await expect( - search({ query: 'party', locale: 'en' }), - ).resolves.toContainEqual( - expect.objectContaining({ - hexcode: 'unicode_hex', - }), - ); - }); - - test('custom results', async () => { - await putCustomEmojiData({ - emojis: [customEmojiFactory({ shortcode: 'party_custom' })], - }); - - await expect( - search({ query: 'party', locale: 'en' }), - ).resolves.toContainEqual( - expect.objectContaining({ - shortcode: 'party_custom', - }), - ); - }); - - test('shortcode results', async () => { - await putEmojiData([rawEmojiFactory()], 'en'); - await putLegacyShortcodes({ - test: ['legacy_smile'], - }); - - await expect( - search({ query: 'legacy', locale: 'en' }), - ).resolves.toContainEqual( - expect.objectContaining({ - hexcode: 'test', - }), - ); - }); - - test('full custom emoji search', async () => { - await putCustomEmojiData({ - emojis: [ - customEmojiFactory({ shortcode: 'arrow' }), - customEmojiFactory({ shortcode: 'party_parrot' }), - ], - }); - - const result = await search({ query: 'arro', locale: 'en' }); - expect(result).toContainEqual( - // Test for ordinary IDB search - expect.objectContaining({ - shortcode: 'arrow', - }), - ); - expect(result).toContainEqual( - // Test for manual iteration search - expect.objectContaining({ - shortcode: 'party_parrot', - }), - ); - }); - - test('limit test', async () => { - await putCustomEmojiData({ - emojis: [ - customEmojiFactory({ shortcode: 'limit' }), - customEmojiFactory({ shortcode: 'limit_extra' }), - ], - }); - - await expect( - search({ query: 'limit', locale: 'en', limit: 1 }), - ).resolves.toEqual([ - expect.objectContaining({ - shortcode: 'limit', - }), - ]); - }); - }); - describe('putEmojiData', () => { test('adds to loaded locales', async () => { const { loadedLocales } = await testGet(); diff --git a/app/javascript/mastodon/features/emoji/database.ts b/app/javascript/mastodon/features/emoji/database.ts index cff7d517ac..14a725457f 100644 --- a/app/javascript/mastodon/features/emoji/database.ts +++ b/app/javascript/mastodon/features/emoji/database.ts @@ -9,12 +9,11 @@ import type { Database } from './db-schema'; import { importEmojiData } from './loader'; import { localeToSegmenter, toSupportedLocale } from './locale'; import { - extractTokens, skinHexcodeToEmoji, transformCustomEmojiData, transformEmojiData, } from './normalize'; -import type { AnyEmojiData, CacheKey, CustomEmojiData } from './types'; +import type { CacheKey } from './types'; import { emojiLogger } from './utils'; const loadedLocales = new Set(); @@ -43,223 +42,22 @@ const loadDB = (() => { return loadPromise; })(); -type ScoreMap = Map; - -export async function search({ - query, - locale: localeString, - limit = 0, -}: { - query: string; - locale: string; - limit?: number; -}) { - performance.mark('emoji-search-start'); - - // Get the locale, and extract tokens from the query. - const locale = await toLoadedLocale(localeString); - const segmenter = localeToSegmenter(locale); - const queryTokens = extractTokens(query, segmenter); - - if (queryTokens.length === 0) { - log('no tokens extracted from query "%s"', query); - return []; - } - const lastToken = queryTokens.at(-1); - if (!lastToken) { - throw new Error('Missing tokens from query'); - } - - log('searching for tokens %o in locale %s', queryTokens, locale); - - // Create an array of emoji results +export async function rawSearch(query: string, locale: Locale, prefix = true) { + await toLoadedLocale(locale); const db = await loadDB(); - const resultArrays: ScoreMap[] = []; - const existingCustomShortcodes = new Set(); - - for (let i = 0; i < queryTokens.length; i++) { - const token = queryTokens[i]; - if (!token) continue; - - // Only query the range for the last token to allow partial matches. - const range = - i === queryTokens.length - 1 - ? IDBKeyRange.lowerBound(token) - : IDBKeyRange.only(token); - - const [unicodeResults, customResults, shortcodeResults] = await Promise.all( - [ - db.getAllFromIndex(locale, 'tokens', range), - db.getAllFromIndex('custom', 'tokens', range), - db.getAllFromIndex('shortcodes', 'shortcodes', range), - ], - ); - const resultMap: ScoreMap = new Map(); - - for (const emoji of unicodeResults) { - const score = getScoreForEmoji(emoji, token); - if (score === null) { - continue; - } - resultMap.set(emoji.hexcode, { ...emoji, score }); - } - - for (const emoji of customResults) { - const score = getScoreForEmoji(emoji, token); - if (score === null) { - continue; - } - existingCustomShortcodes.add(emoji.shortcode); - resultMap.set(emoji.shortcode, { ...emoji, score }); - } - - for (const shortcodeResult of shortcodeResults) { - if (resultMap.has(shortcodeResult.hexcode)) { - continue; - } - const emoji = await db.get(locale, shortcodeResult.hexcode); - if (!emoji) { - continue; - } - // Score the emoji with the legacy shortcode, even though it's not part of the emoji. - const score = getScoreForEmoji( - { - ...emoji, - shortcodes: [...shortcodeResult.shortcodes, ...emoji.shortcodes], - }, - token, - ); - if (score === null) { - continue; - } - resultMap.set(emoji.hexcode, { ...emoji, score }); - } - - log('found %d results for token "%s"', resultMap.size, token); - resultArrays.push(resultMap); - } - - // Utilize maps to find the intersection of all result sets. - const results = Array.from( - resultArrays - .reduce((prev, curr) => { - const intersection: ScoreMap = new Map(); - for (const [code, emoji] of prev) { - if (curr.has(code)) { - intersection.set(code, emoji); - } - } - return intersection; - }) - .values(), - ); - - // If there are no results, try a cursor-based custom emoji search instead. - if (results.length === 0 || results.length < limit) { - const customEmojisFound = await fullCustomSearch( - query, - existingCustomShortcodes, - ); - if (customEmojisFound.length > 0) { - log( - 'cursor search found %d results for "%s"', - customEmojisFound.length, - query, - ); - results.push(...customEmojisFound); - } - } - - // Sort by score, descending. - results.sort((a, b) => a.score - b.score); - - const time = performance.measure('emoji-search-end', 'emoji-search-start'); - log( - 'search for "%s" in locale %s returned %d results and took %dms', - query, - locale, - results.length, - time.duration, - ); - if (limit > 0) { - return results.slice(0, limit); - } - return results; -} - -function getScoreForEmoji( - emoji: AnyEmojiData, - query: string, - checkTokens = true, -) { - const id = 'shortcode' in emoji ? emoji.shortcode : emoji.label; - if (id === query) { - return 0; - } - - let index = 1; - const searchTokens = [id]; - if (checkTokens) { - // Check shortcodes before tokens as they are more important. - if ('shortcodes' in emoji) { - searchTokens.push(...emoji.shortcodes); - } - searchTokens.push(...emoji.tokens); - } - for (const token of searchTokens) { - const tokenIndex = token.indexOf(query); - if (tokenIndex !== -1) { - return index + tokenIndex / token.length; - } - index++; - } - - return null; -} - -async function fullCustomSearch(query: string, existing = new Set()) { - const db = await loadDB(); - const trx = db.transaction('custom', 'readonly'); - const foundEmojis = new Set(); - - // First iterate over chunks of 1,000 custom emoji keys and find any matches. - const chunkSize = 1_000; - let lastKey: string | null = null; - let keys: string[] = []; - do { - const keyRange = lastKey ? IDBKeyRange.lowerBound(lastKey, true) : null; - keys = await trx.store.getAllKeys(keyRange, chunkSize); - - if (keys.length === 0) { - break; - } - log('cursor search got batch of %d emojis', keys.length); - lastKey = keys.at(-1) ?? null; - - for (const key of keys) { - if (!foundEmojis.has(key) && !existing.has(key) && key.includes(query)) { - foundEmojis.add(key); - } - } - } while (keys.length === chunkSize); - - // Next get the full emojis for all matches. - const emojis = await Promise.all( - foundEmojis.keys().map((key) => trx.store.get(key)), - ); - const results: (CustomEmojiData & { score: number })[] = []; - for (const emoji of emojis) { - if (emoji) { - const score = getScoreForEmoji(emoji, query, false); - if (score && score > 0) { - results.push({ - score, - ...emoji, - }); - } - } - } - return results; + const range = prefix + ? IDBKeyRange.lowerBound(query) + : IDBKeyRange.only(query); + const [unicodeResults, customResults, shortcodeResults] = await Promise.all([ + db.getAllFromIndex(locale, 'tokens', range), + db.getAllFromIndex('custom', 'tokens', range), + db.getAllFromIndex('shortcodes', 'shortcodes', range), + ]); + return { + unicodeResults, + customResults, + shortcodeResults, + }; } export async function putEmojiData(emojis: CompactEmoji[], locale: Locale) { @@ -369,6 +167,9 @@ export async function loadCustomEmojiByShortcode(shortcode: string) { } export async function searchCustomEmojisByShortcodes(shortcodes: string[]) { + if (shortcodes.length === 0) { + return []; + } const db = await loadDB(); const sortedCodes = shortcodes.toSorted(); const results = await db.getAll( @@ -378,6 +179,15 @@ export async function searchCustomEmojisByShortcodes(shortcodes: string[]) { return results.filter((emoji) => shortcodes.includes(emoji.shortcode)); } +export async function loadCustomEmojiKeys( + query?: string | null, + chunkSize = 1_000, +) { + const db = await loadDB(); + const keyRange = query ? IDBKeyRange.lowerBound(query, true) : null; + return db.getAllKeys('custom', keyRange, chunkSize); +} + export async function loadAllCustomEmoji() { const db = await loadDB(); const cacheValue = await db.get('etags', 'custom'); diff --git a/app/javascript/mastodon/features/emoji/db-schema.ts b/app/javascript/mastodon/features/emoji/db-schema.ts index b809ef63a8..661c658979 100644 --- a/app/javascript/mastodon/features/emoji/db-schema.ts +++ b/app/javascript/mastodon/features/emoji/db-schema.ts @@ -64,7 +64,7 @@ type Transaction = export type Database = IDBPDatabase; const DATABASE_NAME = 'mastodon-emoji'; -const SCHEMA_VERSION = 4; +const SCHEMA_VERSION = 5; export async function openEmojiDB() { try { diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index 3583d178eb..29c0dd16bb 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -60,7 +60,7 @@ export function transformEmojiData( ...extract(label), ...(normalizedEmoticons ?? []), ]), - ].sort((a, b) => a.localeCompare(b)); + ]; const res: UnicodeEmojiData = { tokens, diff --git a/app/javascript/mastodon/features/emoji/picker.ts b/app/javascript/mastodon/features/emoji/picker.ts index 9a49dd66ac..c83df7ba8c 100644 --- a/app/javascript/mastodon/features/emoji/picker.ts +++ b/app/javascript/mastodon/features/emoji/picker.ts @@ -7,6 +7,7 @@ import { } from '@/mastodon/store/typed_functions'; import { createLimitedCache } from '@/mastodon/utils/cache'; +import { search } from './search'; import { emojiLogger } from './utils'; const log = emojiLogger('picker'); @@ -37,7 +38,6 @@ export async function emojiMartSearch( return cachedResult; } - const { search } = await import('./database'); const results = await search({ query, locale, limit }); const legacyResults = results.map((emoji) => 'shortcode' in emoji diff --git a/app/javascript/mastodon/features/emoji/search.test.ts b/app/javascript/mastodon/features/emoji/search.test.ts new file mode 100644 index 0000000000..c861fe4532 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/search.test.ts @@ -0,0 +1,188 @@ +import type { CompactEmoji } from 'emojibase'; + +import { unicodeEmojiFactory, customEmojiFactory } from '@/testing/factories'; + +import { + putEmojiData, + putCustomEmojiData, + putLegacyShortcodes, + testGet, + testClear, +} from './database'; +import { search } from './search'; + +function rawEmojiFactory(data: Partial = {}): CompactEmoji { + const factory = unicodeEmojiFactory(); + return { + ...factory, + ...data, + tags: data.tags ?? factory.tokens, + }; +} + +describe('search', () => { + beforeEach(async () => { + await testGet(); // Loads the database schema. + await putEmojiData([], 'en'); + }); + + afterEach(() => { + testClear(); + indexedDB = new IDBFactory(); + }); + + test('test no query tokens', async () => { + await putEmojiData([rawEmojiFactory()], 'en'); + await expect(search({ query: ' ', locale: 'en' })).resolves.toEqual([]); + }); + + test('unicode results', async () => { + await putEmojiData( + [ + rawEmojiFactory({ + hexcode: 'unicode_hex', + label: 'Party Popper', + shortcodes: ['party_popper'], + unicode: '🎉', + }), + ], + 'en', + ); + + await expect( + search({ query: 'party', locale: 'en' }), + ).resolves.toContainEqual( + expect.objectContaining({ + hexcode: 'unicode_hex', + }), + ); + }); + + test('custom results', async () => { + await putCustomEmojiData({ + emojis: [customEmojiFactory({ shortcode: 'party_custom' })], + }); + + await expect( + search({ query: 'party', locale: 'en' }), + ).resolves.toContainEqual( + expect.objectContaining({ + shortcode: 'party_custom', + }), + ); + }); + + test('shortcode results', async () => { + await putEmojiData([rawEmojiFactory()], 'en'); + await putLegacyShortcodes({ + test: ['legacy_smile'], + }); + + await expect( + search({ query: 'legacy', locale: 'en' }), + ).resolves.toContainEqual( + expect.objectContaining({ + hexcode: 'test', + }), + ); + }); + + test('full custom emoji search', async () => { + await putCustomEmojiData({ + emojis: [ + customEmojiFactory({ shortcode: 'arrow' }), + customEmojiFactory({ shortcode: 'party_parrot' }), + ], + }); + + const result = await search({ query: 'arro', locale: 'en' }); + expect(result).toContainEqual( + // Test for ordinary IDB search + expect.objectContaining({ + shortcode: 'arrow', + }), + ); + expect(result).toContainEqual( + // Test for manual iteration search + expect.objectContaining({ + shortcode: 'party_parrot', + }), + ); + }); + + test('limit test', async () => { + await putCustomEmojiData({ + emojis: [ + customEmojiFactory({ shortcode: 'limit' }), + customEmojiFactory({ shortcode: 'limit_extra' }), + ], + }); + + await expect( + search({ query: 'limit', locale: 'en', limit: 1 }), + ).resolves.toEqual([ + expect.objectContaining({ + shortcode: 'limit', + }), + ]); + }); + + test('prefix matches', async () => { + await putCustomEmojiData({ + emojis: [ + customEmojiFactory({ shortcode: 'sob_other' }), + customEmojiFactory({ shortcode: 'meow_sob' }), + ], + }); + await putEmojiData( + [ + rawEmojiFactory({ + label: 'loudly crying face', + hexcode: '1F62D', + shortcodes: ['loudly_crying_face'], + tags: ['bawling', 'cry', 'sad', 'sob', 'tear', 'tears', 'unhappy'], + emoticon: ":'o", + unicode: '😭', + }), + ], + 'en', + ); + + const results = await search({ query: 'sob', locale: 'en' }); + + expect(results).toHaveLength(3); + expect(results).toEqual([ + expect.objectContaining({ shortcode: 'sob_other' }), + expect.objectContaining({ shortcode: 'meow_sob' }), + expect.objectContaining({ hexcode: '1F62D' }), + ]); + }); + + test('shortcode matches', async () => { + await putLegacyShortcodes({ '1F62D': 'sob' }); + await putEmojiData( + [ + rawEmojiFactory({ + label: 'loudly crying face', + hexcode: '1F62D', + shortcodes: ['loudly_crying_face'], + tags: ['bawling', 'cry', 'sad', 'sob', 'tear', 'tears', 'unhappy'], + emoticon: ":'o", + unicode: '😭', + }), + ], + 'en', + ); + await putCustomEmojiData({ + emojis: [customEmojiFactory({ shortcode: 'sob_other' })], + }); + + const results = await search({ query: 'sob', locale: 'en' }); + + expect(results).toHaveLength(2); + expect(results).toEqual([ + expect.objectContaining({ hexcode: '1F62D' }), + expect.objectContaining({ shortcode: 'sob_other' }), + ]); + }); +}); diff --git a/app/javascript/mastodon/features/emoji/search.ts b/app/javascript/mastodon/features/emoji/search.ts new file mode 100644 index 0000000000..dcdd6be500 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/search.ts @@ -0,0 +1,407 @@ +import { log } from 'debug'; +import type { ArrayValues, KeysOfUnion } from 'type-fest'; + +import { + loadCustomEmojiKeys, + loadEmojiByHexcode, + rawSearch, + searchCustomEmojisByShortcodes, +} from './database'; +import { localeToSegmenter, toSupportedLocale } from './locale'; +import { extractTokens } from './normalize'; +import type { AnyEmojiData, CustomEmojiData } from './types'; + +/* +Emoji search logic: +1. When provided a string, extract all tokens and iterate over each. +2. For each token, do an IDB lookup to get any matches on emojis and shortcodes. +3. Score each emoji found (see below). +4. Create a final list of emojis that is the intersection of all token results, with the scores combined. +5. If not enough emoji are found, do a cursor search on custom emojis. +6. Sort and return the emoji by ranked score, stopping when the optional limit is reached. + +Scoring functions as follows: +- Go over every scoreRanking field in order and extract score data. +- Scores prefer exact match, then prefix match, and lastly substring match. +- When sorting, prefer identifier fields first, then score, then category, and lastly prioritize Unicode emojis. +*/ + +const scoreRanking = [ + 'label', + 'shortcode', + 'emoticons', + 'shortcodes', + 'tokens', +] as const satisfies KeysOfUnion[]; +type ScoreRankingKeys = ArrayValues; +type ScoreRanking = Record; + +const identifierFields = new Set([ + 'label', + 'shortcode', + 'emoticons', + 'shortcodes', +]); + +interface BestRank { + categoryWeight: number; + score: number; + fieldWeight: number; +} +type ScoredEmoji = AnyEmojiData & { scores: ScoreRanking }; +type RankedEmoji = AnyEmojiData & { rank: BestRank }; +type ScoreMap = Map; + +export async function search({ + query: rawQuery, + locale: localeString, + limit = 0, +}: { + query: string; + locale: string; + limit?: number; +}) { + performance.mark('emoji-search-start'); + + // Get the locale, and extract tokens from the query. + const locale = toSupportedLocale(localeString); + const segmenter = localeToSegmenter(locale); + const query = rawQuery.toLowerCase(); + const queryTokens = extractTokens(query, segmenter); + + if (queryTokens.length === 0) { + log('no tokens extracted from query "%s"', query); + return []; + } + const lastToken = queryTokens.at(-1); + if (!lastToken) { + throw new Error('Missing tokens from query'); + } + + log('searching for tokens %o in locale %s', queryTokens, locale); + + // Create an array of emoji results + const resultArrays: ScoreMap[] = []; + + for (let i = 0; i < queryTokens.length; i++) { + const token = queryTokens[i]; + if (!token) continue; + + // Only query the range for the last token to allow partial matches. + const { unicodeResults, customResults, shortcodeResults } = await rawSearch( + token, + locale, + i === queryTokens.length - 1, + ); + const resultMap: ScoreMap = new Map(); + const checkedSet = new Set(); + + // Score unicode results. + for (const emoji of unicodeResults) { + if (checkedSet.has(emoji.hexcode)) { + continue; + } + checkedSet.add(emoji.hexcode); + const scores = getScoreForEmoji(emoji, token); + if (scores) { + resultMap.set(emoji.hexcode, { ...emoji, scores }); + } + } + + // Score custom emojis. + for (const emoji of customResults) { + if (checkedSet.has(emoji.shortcode)) { + continue; + } + checkedSet.add(emoji.shortcode); + const scores = getScoreForEmoji(emoji, token); + if (scores) { + resultMap.set(emoji.shortcode, { ...emoji, scores }); + } + } + + // Score based on legacy shortcodes, using the higher score if there's a match. + for (const shortcodeResult of shortcodeResults) { + const emoji = + resultMap.get(shortcodeResult.hexcode) ?? + (await loadEmojiByHexcode(shortcodeResult.hexcode, locale)); + if (!emoji || !('hexcode' in emoji)) { + continue; + } + + const newScores = getScoreForEmoji( + { + ...emoji, + shortcodes: shortcodeResult.shortcodes, + }, + token, + ); + if (!newScores) { + continue; + } + const oldScores = resultMap.get(emoji.hexcode)?.scores; + const scores = oldScores + ? combineEmojiScores(oldScores, newScores) + : newScores; + resultMap.set(emoji.hexcode, { + ...emoji, + shortcodes: [...shortcodeResult.shortcodes, ...emoji.shortcodes], + scores, + }); + } + + log('found %d results for token "%s"', resultMap.size, token); + resultArrays.push(resultMap); + } + + // Iterate over all maps, getting a combined score for emojis that exist in all results. + const allEmojiIds = resultArrays.reduce((prev, map) => { + if (prev.size === 0) { + return new Set(map.keys()); + } + return new Set(map.keys()).intersection(prev); + }, new Set()); + + const finalMap: ScoreMap = new Map(); + for (const resultArray of resultArrays) { + for (const [id, emoji] of resultArray.entries()) { + if (!allEmojiIds.has(id)) { + continue; + } + const existingEmoji = finalMap.get(id); + if (!existingEmoji) { + finalMap.set(id, emoji); + } else { + finalMap.set(id, { + ...existingEmoji, + scores: combineEmojiScores(existingEmoji.scores, emoji.scores), + }); + } + } + } + const mixedResults = Array.from(finalMap.values()); + + // If there are no results, try a cursor-based custom emoji search instead. + if (mixedResults.length === 0 || mixedResults.length < limit) { + const customEmojisFound = await fullCustomSearch(query, allEmojiIds); + if (customEmojisFound.length > 0) { + log( + 'cursor search found %d results for "%s"', + customEmojisFound.length, + query, + ); + mixedResults.push(...customEmojisFound); + } + } + + const rankedEmojis = mixedResults.map(({ scores, ...emoji }) => ({ + ...emoji, + rank: getBestRank(scores), + })); + + const results: RankedEmoji[] = []; + const resultEmojis = new Set(); + for (const result of rankedEmojis.toSorted(compareRankedEmoji)) { + const id = getIdentifier(result); + if (resultEmojis.has(id)) { + continue; + } + results.push(result); + resultEmojis.add(id); + if (limit > 0 && results.length >= limit) { + break; + } + } + + const time = performance.measure('emoji-search-end', 'emoji-search-start'); + log( + 'search for "%s" in locale %s returned %d results and took %dms', + query, + locale, + mixedResults.length, + time.duration, + ); + return results; +} + +function hasField( + emoji: AnyEmojiData, + field: string, +): field is keyof typeof emoji { + return Object.hasOwn(emoji, field); +} + +function getIdentifier(emoji: AnyEmojiData) { + return 'shortcode' in emoji ? emoji.shortcode : emoji.hexcode; +} + +// Creates ranked scores for a given emoji. +function getScoreForEmoji(emoji: AnyEmojiData, query: string) { + const scores = Object.fromEntries( + scoreRanking.map((field) => [field, -1]), + ) as ScoreRanking; + let hasScore = false; + + for (const field of scoreRanking) { + if (hasField(emoji, field)) { + const value = emoji[field] as string | string[] | undefined; + if (value === undefined) { + continue; + } + const tokens = Array.isArray(value) ? value : [value]; + const score = getScoreForEmojiTokens( + tokens.map((token) => token.toLowerCase()), + query, + ); + + if (score >= 0) { + scores[field] = score; + hasScore = true; + } + } + } + + if (!hasScore) { + return null; + } + + return scores; +} + +function combineEmojiScores(a: ScoreRanking, b: ScoreRanking): ScoreRanking { + const scores = Object.fromEntries( + scoreRanking.map((field) => [field, -1]), + ) as ScoreRanking; + + for (const rank of scoreRanking) { + if (a[rank] === -1) { + scores[rank] = b[rank]; + } else if (b[rank] === -1) { + scores[rank] = a[rank]; + } else { + scores[rank] = Math.min(a[rank], b[rank]); + } + } + + return scores; +} + +// Compares two scored emojis by getting the best rank. +function compareRankedEmoji(a: RankedEmoji, b: RankedEmoji): number { + const rankA = a.rank; + const rankB = b.rank; + + // Identifier matches always outrank token matches, regardless of score. + if (rankA.categoryWeight !== rankB.categoryWeight) { + return rankA.categoryWeight - rankB.categoryWeight; + } + // Within the same category, compare the scores directly. + if (rankA.score !== rankB.score) { + return rankA.score - rankB.score; + } + // If equal, compare field weights. + if (rankA.fieldWeight !== rankB.fieldWeight) { + return rankA.fieldWeight - rankB.fieldWeight; + } + + // Lastly prioritize Unicode emojis. + const aIsCustom = hasField(a, 'shortcode'); + const bIsCustom = hasField(b, 'shortcode'); + if (aIsCustom !== bIsCustom) { + return aIsCustom ? -1 : 1; + } + + return 0; +} + +// Extracts the best rank for a score ranking. +function getBestRank(scores: ScoreRanking): BestRank { + let best: BestRank | null = null; + + // Use the index to determine field weight. + for (const [fieldWeight, field] of scoreRanking.entries()) { + const score = scores[field]; + if (score < 0) { + continue; + } + // Also weight identifier fields over other fields. + const categoryWeight = identifierFields.has(field) ? 0 : 1; + if ( + !best || + categoryWeight < best.categoryWeight || + (categoryWeight === best.categoryWeight && + (score < best.score || + (score === best.score && fieldWeight < best.fieldWeight))) + ) { + best = { categoryWeight, score, fieldWeight }; + } + } + + return ( + best ?? { + categoryWeight: 2, + score: Infinity, + fieldWeight: scoreRanking.length, + } + ); +} + +function getScoreForEmojiTokens(tokens: string[], query: string) { + let lowestScore = -1; + + for (const token of tokens) { + let score = -1; + // Priority: exact match, prefix match, substring, + if (token === query) { + score = 0; + } else if (token.startsWith(query)) { + score = 1 + query.length / token.length; + } else if (token.includes(query)) { + score = 2 + query.length / token.length; + } // TODO: Add fuzzy search if needed + + if (score >= 0 && (score < lowestScore || lowestScore < 0)) { + lowestScore = score; + } + } + + return lowestScore; +} + +async function fullCustomSearch(query: string, existing = new Set()) { + const foundEmojis = new Set(); + + // First iterate over chunks of 1,000 custom emoji keys and find any matches. + const chunkSize = 1_000; + let lastKey: string | null = null; + let keys: string[] = []; + do { + keys = await loadCustomEmojiKeys(lastKey, chunkSize); + + if (keys.length === 0) { + break; + } + log('cursor search got batch of %d emojis', keys.length); + lastKey = keys.at(-1) ?? null; + + for (const key of keys) { + if (!foundEmojis.has(key) && !existing.has(key) && key.includes(query)) { + foundEmojis.add(key); + } + } + } while (keys.length === chunkSize); + + // Next get the full emojis for all matches. + const emojis = await searchCustomEmojisByShortcodes(Array.from(foundEmojis)); + const results: (CustomEmojiData & { scores: ScoreRanking })[] = []; + for (const emoji of emojis) { + const scores = getScoreForEmoji(emoji, query); + if (scores) { + results.push({ + scores, + ...emoji, + }); + } + } + return results; +} diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index 8ac03ec2ad..fa2b8e63b8 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -126,11 +126,12 @@ export function unicodeEmojiFactory( data: Partial = {}, ): UnicodeEmojiData { return { + emoticons: undefined, hexcode: 'test', label: 'Test', unicode: '🧪', shortcodes: ['test_emoji'], - tokens: ['emoji', 'test'], + tokens: ['test', 'emoji'], group: 1, order: 1, ...data, diff --git a/package.json b/package.json index 98c0ffbd3b..a49823ae5a 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "tesseract.js": "^7.0.0", "tiny-queue": "^0.2.1", "twitter-text": "3.1.0", + "type-fest": "^5.8.0", "use-debounce": "^10.0.0", "vite": "^8.0.0", "vite-plugin-manifest-sri": "^0.2.0", diff --git a/yarn.lock b/yarn.lock index 60d26863e0..83f8a2a91d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3012,6 +3012,7 @@ __metadata: tesseract.js: "npm:^7.0.0" tiny-queue: "npm:^0.2.1" twitter-text: "npm:3.1.0" + type-fest: "npm:^5.8.0" typescript: "npm:~6.0.0" typescript-eslint: "npm:^8.55.0" typescript-plugin-css-modules: "npm:^5.2.0" @@ -13993,6 +13994,15 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^5.8.0": + version: 5.8.0 + resolution: "type-fest@npm:5.8.0" + dependencies: + tagged-tag: "npm:^1.0.0" + checksum: 10c0/c8aae118a763d550a9552a511dff6b71840a23dab4edf693cf1c4df22596942794e6f6723389bd9036a90182249d915158bacf0815a1ae87f05f901b1d5f574e + languageName: node + linkType: hard + "type-is@npm:^2.0.1": version: 2.0.1 resolution: "type-is@npm:2.0.1" From f90b89b7a9af79e059ef78f4185d582885710b72 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 21 Jul 2026 10:47:36 +0200 Subject: [PATCH 06/12] Fix `Account::Merging` concern not supporting Quotes or Collections, refactor it (#39884) --- app/models/concerns/account/merging.rb | 85 +++++++------------- spec/models/concerns/account/merging_spec.rb | 21 +++++ 2 files changed, 50 insertions(+), 56 deletions(-) create mode 100644 spec/models/concerns/account/merging_spec.rb diff --git a/app/models/concerns/account/merging.rb b/app/models/concerns/account/merging.rb index 181061c37e..03dc621fae 100644 --- a/app/models/concerns/account/merging.rb +++ b/app/models/concerns/account/merging.rb @@ -1,8 +1,30 @@ # frozen_string_literal: true +# TODO: remove some time after 4.7.0 module Account::Merging extend ActiveSupport::Concern + ACCOUNT_MERGING_CLASSES = { + account_id: [ + Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite, + Follow, FollowRequest, Block, Mute, + AccountModerationNote, AccountPin, AccountStat, ListAccount, + PollVote, Mention, AccountDeletionRequest, AccountNote, FollowRecommendationSuppression, + Appeal, TagFollow, Quote, Collection, CollectionItem + ], + from_account_id: [ + Notification, NotificationPermission, NotificationRequest + ], + target_account_id: [ + Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin, AccountNote + ], + reference_account_id: [CanonicalEmailBlock], + account_warning_id: [Appeal], + local_account_id: [SeveredRelationship], + remote_account_id: [SeveredRelationship], + quoted_account_id: [Quote], + }.freeze + def merge_with!(other_account) # Since it's the same remote resource, the remote resource likely # already believes we are following/blocking, so it's safe to @@ -11,65 +33,16 @@ module Account::Merging # account already, therefore mass update will not work and we need # to check for (and skip past) uniqueness errors - owned_classes = [ - Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite, - Follow, FollowRequest, Block, Mute, - AccountModerationNote, AccountPin, AccountStat, ListAccount, - PollVote, Mention, AccountDeletionRequest, AccountNote, FollowRecommendationSuppression, - Appeal, TagFollow - ] - - owned_classes.each do |klass| - klass.where(account_id: other_account.id).reorder(nil).find_each do |record| - record.update_attribute(:account_id, id) - rescue ActiveRecord::RecordNotUnique - next + ACCOUNT_MERGING_CLASSES.each do |attribute, classes| + classes.each do |klass| + klass.where({ attribute => other_account.id }).reorder(nil).find_each do |record| + record.update_attribute(attribute, id) + rescue ActiveRecord::RecordNotUnique + next + end end end - [ - Notification, NotificationPermission, NotificationRequest - ].each do |klass| - klass.where(from_account_id: other_account.id).reorder(nil).find_each do |record| - record.update_attribute(:from_account_id, id) - rescue ActiveRecord::RecordNotUnique - next - end - end - - target_classes = [ - Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin, - AccountNote - ] - - target_classes.each do |klass| - klass.where(target_account_id: other_account.id).reorder(nil).find_each do |record| - record.update_attribute(:target_account_id, id) - rescue ActiveRecord::RecordNotUnique - next - end - end - - CanonicalEmailBlock.where(reference_account_id: other_account.id).find_each do |record| - record.update_attribute(:reference_account_id, id) - end - - Appeal.where(account_warning_id: other_account.id).find_each do |record| - record.update_attribute(:account_warning_id, id) - end - - SeveredRelationship.about_local_account(other_account).reorder(nil).find_each do |record| - record.update_attribute(:local_account_id, id) - rescue ActiveRecord::RecordNotUnique - next - end - - SeveredRelationship.about_remote_account(other_account).reorder(nil).find_each do |record| - record.update_attribute(:remote_account_id, id) - rescue ActiveRecord::RecordNotUnique - next - end - # Some follow relationships have moved, so the cache is stale Rails.cache.delete_matched("followers_hash:#{id}:*") Rails.cache.delete_matched("relationships:#{id}:*") diff --git a/spec/models/concerns/account/merging_spec.rb b/spec/models/concerns/account/merging_spec.rb new file mode 100644 index 0000000000..47248c22db --- /dev/null +++ b/spec/models/concerns/account/merging_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Account::Merging do + let!(:account) { Fabricate(:account) } + + describe '#merge_with!' do + let(:other_account) { Fabricate(:account) } + let!(:status) { Fabricate(:status, account: other_account) } + let!(:follow) { Fabricate(:follow, account: other_account) } + let!(:reverse_follow) { Fabricate(:follow, target_account: other_account) } + + it 'reattributes records' do + expect { account.merge_with!(other_account) } + .to change { status.reload.account_id }.to(account.id) + .and change { follow.reload.account_id }.to(account.id) + .and change { reverse_follow.reload.target_account_id }.to(account.id) + end + end +end From af8d57c8862d129c2622d1fda1b51d277d9e42a9 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 21 Jul 2026 11:18:37 +0200 Subject: [PATCH 07/12] Update dependency `rails-html-sanitizer` --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 597ff03407..0832f9ddd9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -428,7 +428,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.25.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -672,8 +672,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.7.0) - loofah (~> 2.25) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (8.1.0) i18n (>= 0.7, < 2) From 61a1238bd7173bb5312fd52f8d32c4d9404e76e4 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 21 Jul 2026 17:03:01 +0200 Subject: [PATCH 08/12] Fix lax relevancy check in inbound activity processing (#39892) --- app/lib/activitypub/activity/create.rb | 40 +++++++++++--------- app/lib/activitypub/parser/status_parser.rb | 16 ++++---- spec/lib/activitypub/activity/create_spec.rb | 5 ++- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 9d79a963ac..0ed0a10f37 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -15,7 +15,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity private def create_status - return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists? || !related_to_local_activity? + return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists? + + @status_parser = ActivityPub::Parser::StatusParser.new( + @json, + followers_collection: @account.followers_url, + following_collection: @account.following_url, + actor_uri: ActivityPub::TagManager.instance.uri_for(@account), + object: @object + ) + + return reject_payload! unless related_to_local_activity? with_redis_lock("create:#{object_uri}") do Status.uncached do @@ -34,13 +44,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @status end - def audience_to - as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) } - end - - def audience_cc - as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) } - end + delegate :audience_to, :audience_cc, to: :@status_parser def process_status @tags = [] @@ -91,14 +95,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def process_status_params - @status_parser = ActivityPub::Parser::StatusParser.new( - @json, - followers_collection: @account.followers_url, - following_collection: @account.following_url, - actor_uri: ActivityPub::TagManager.instance.uri_for(@account), - object: @object - ) - attachment_ids = process_attachments.take(Status::MEDIA_ATTACHMENTS_LIMIT).map(&:id) @params = { @@ -454,8 +450,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def related_to_local_activity? - fetch? || followed_by_local_accounts? || requested_through_relay? || - responds_to_followed_account? || addresses_local_accounts? + return true if fetch? + + case @status_parser.visibility + when :public, :unlisted + followed_by_local_accounts? || requested_through_relay? || responds_to_followed_account? || addresses_local_accounts? + when :private + followed_by_local_accounts? || addresses_local_accounts? + when :direct + addresses_local_accounts? + end end def responds_to_followed_account? diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index bc2abc0f1a..fc332f567e 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -166,6 +166,14 @@ class ActivityPub::Parser::StatusParser equals_or_includes_any?(@object['type'], ActivityPub::Activity::CONVERTED_TYPES) end + def audience_to + as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) } + end + + def audience_cc + as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) } + end + private def quote_subpolicy(subpolicy) @@ -197,14 +205,6 @@ class ActivityPub::Parser::StatusParser end end - def audience_to - as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) } - end - - def audience_cc - as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) } - end - def summary_language_map? @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty? end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index c68e9bd64a..dac1b2a33e 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -1284,7 +1284,7 @@ RSpec.describe ActivityPub::Activity::Create do subject.perform end - let(:object_json) { build_object } + let(:object_json) { build_object(to: 'http://example.com/followers') } it 'creates status' do status = sender.statuses.first @@ -1300,7 +1300,8 @@ RSpec.describe ActivityPub::Activity::Create do let!(:local_status) { Fabricate(:status) } let(:object_json) do build_object( - inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status) + inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status), + cc: 'https://www.w3.org/ns/activitystreams#Public' ) end From 799a0021882282a076563318625bc084641a11df Mon Sep 17 00:00:00 2001 From: Shlee Date: Wed, 22 Jul 2026 19:59:59 +0930 Subject: [PATCH 09/12] Fix typo in quotes list error handling (#39904) --- app/javascript/mastodon/reducers/status_lists.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index 447dde6ecb..6112c4b88c 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -159,7 +159,7 @@ export default function statusLists(state = initialState, action) { else if (fetchQuotes.pending.match(action)) return state.setIn(['quotes', 'isLoading'], true).setIn(['quotes', 'statusId'], action.meta.arg.statusId); else if (fetchQuotes.rejected.match(action)) - return state.setIn(['quotes', 'isLoading', false]).setIn(['quotes', 'statusId'], action.meta.arg.statusId); + return state.setIn(['quotes', 'isLoading'], false).setIn(['quotes', 'statusId'], action.meta.arg.statusId); else return state; } From 503aee6be474d395eaa4038f79ca636782a7ec63 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 22 Jul 2026 16:29:39 +0200 Subject: [PATCH 10/12] Fix `ActivityPub::Activity::Create` trying to re-create known statuses when author changes (#39916) --- app/lib/activitypub/activity/create.rb | 5 +++- spec/lib/activitypub/activity/create_spec.rb | 26 +++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 0ed0a10f37..456f4658b7 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -36,6 +36,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity if @status.nil? process_status + elsif @status.account_id != @account.id + Rails.logger.debug { "Not processing #{object_uri}: authorship change is not supported" } + return reject_payload! elsif @options[:delivered_to_account_id].present? postprocess_audience_and_deliver end @@ -91,7 +94,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def find_existing_status status = status_from_uri(object_uri) status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present? - status if status&.account_id == @account.id + status end def process_status_params diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index dac1b2a33e..2b3c040fa2 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -459,6 +459,30 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'when the status is already known' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + build_object( + to: ActivityPub::TagManager.instance.uri_for(recipient) + ) + end + + let!(:status) { Fabricate(:status, uri: object_json[:id], account: sender, text: object_json[:content]) } + + it 'keeps the status intact' do + expect(subject.perform).to eq status + end + + context 'when the known status is attributed to a different actor' do + let!(:status) { Fabricate(:status, uri: object_json[:id], account: Fabricate(:remote_account)) } + + it 'returns nil' do + expect(subject.perform).to be_nil + end + end + end + context 'when direct' do let(:recipient) { Fabricate(:account) } @@ -1378,7 +1402,7 @@ RSpec.describe ActivityPub::Activity::Create do def build_object(options = {}) { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + id: [ActivityPub::TagManager.instance.uri_for(sender), '/bar'].join, type: 'Note', content: 'Lorem ipsum', }.merge(options) From d813ddd1db920560704cb06ed8688e5c2c3e358c Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 22 Jul 2026 16:38:49 +0200 Subject: [PATCH 11/12] Fix Web Push subscription deletion endpoint incorrectly expecting anti-CSRF tokens (#39918) --- app/controllers/api/web/push_subscriptions_controller.rb | 2 ++ spec/requests/api/web/push_subscriptions_spec.rb | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 2edd92dbc7..34c8cc0793 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -6,6 +6,8 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController before_action :destroy_previous_subscriptions, only: :create, if: :prior_subscriptions? after_action :update_session_with_subscription, only: :create + skip_forgery_protection only: :destroy + def create @push_subscription = ::Web::PushSubscription.create!(web_push_subscription_params) diff --git a/spec/requests/api/web/push_subscriptions_spec.rb b/spec/requests/api/web/push_subscriptions_spec.rb index 3c33f0d2d2..4f4b56ba73 100644 --- a/spec/requests/api/web/push_subscriptions_spec.rb +++ b/spec/requests/api/web/push_subscriptions_spec.rb @@ -39,6 +39,15 @@ RSpec.describe 'API Web Push Subscriptions' do describe 'DELETE /api/web/push_subscriptions/:id' do subject { delete api_web_push_subscription_path(token) } + around do |example| + old = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + + example.run + + ActionController::Base.allow_forgery_protection = old + end + context 'when the subscription exists' do let!(:web_push_subscription) do Fabricate(:web_push_subscription) From 35fce54cc29466b848eef9b721be810f77fd78f2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:36:19 +0200 Subject: [PATCH 12/12] Update dependency @vitest/browser to v4.1.10 [SECURITY] (#39915) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Claire --- package.json | 4 +- yarn.lock | 138 +++++++++++++++++++++++++++++---------------------- 2 files changed, 81 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index a49823ae5a..c6a39fbf8e 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "@types/redux-immutable": "^4.0.3", "@types/requestidlecallback": "^0.3.5", "@vitest/browser": "^4.1.0", - "@vitest/browser-playwright": "^4.1.7", + "@vitest/browser-playwright": "^4.1.10", "@vitest/coverage-v8": "^4.1.0", "@vitest/ui": "^4.1.0", "chromatic": "^17.0.0", @@ -185,7 +185,7 @@ "typescript": "~6.0.0", "typescript-eslint": "^8.55.0", "typescript-plugin-css-modules": "^5.2.0", - "vitest": "^4.1.7" + "vitest": "^4.1.10" }, "resolutions": { "@types/react": "^18.2.7", diff --git a/yarn.lock b/yarn.lock index 83f8a2a91d..9b66fdb7bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2924,7 +2924,7 @@ __metadata: "@vitejs/plugin-legacy": "npm:^8.0.0" "@vitejs/plugin-react": "npm:^6.0.0" "@vitest/browser": "npm:^4.1.0" - "@vitest/browser-playwright": "npm:^4.1.7" + "@vitest/browser-playwright": "npm:^4.1.10" "@vitest/coverage-v8": "npm:^4.1.0" "@vitest/ui": "npm:^4.1.0" arrow-key-navigation: "npm:^1.2.0" @@ -3020,7 +3020,7 @@ __metadata: vite: "npm:^8.0.0" vite-plugin-manifest-sri: "npm:^0.2.0" vite-plugin-svgr: "npm:^5.0.0" - vitest: "npm:^4.1.7" + vitest: "npm:^4.1.10" wicg-inert: "npm:^3.1.2" peerDependenciesMeta: react: @@ -5233,38 +5233,38 @@ __metadata: languageName: node linkType: hard -"@vitest/browser-playwright@npm:^4.1.7": - version: 4.1.9 - resolution: "@vitest/browser-playwright@npm:4.1.9" +"@vitest/browser-playwright@npm:^4.1.10": + version: 4.1.10 + resolution: "@vitest/browser-playwright@npm:4.1.10" dependencies: - "@vitest/browser": "npm:4.1.9" - "@vitest/mocker": "npm:4.1.9" + "@vitest/browser": "npm:4.1.10" + "@vitest/mocker": "npm:4.1.10" tinyrainbow: "npm:^3.1.0" peerDependencies: playwright: "*" - vitest: 4.1.9 + vitest: 4.1.10 peerDependenciesMeta: playwright: optional: false - checksum: 10c0/4c042c980bb0aa950646d4d1f0f131f44156141d86f54eb96f5f69863ed05720cac7ef1a0901ab71e18929a62c41e5d64a428b2612ea708905bf423ff961dcf4 + checksum: 10c0/161530da4da9c061875c0e80c2c94e93658bf92514f2e5173c04299e17a6021feb209ef8fb5e14e880c8c8b2b9e7fc4f3ec7a346c8c4a77831864597331257fc languageName: node linkType: hard -"@vitest/browser@npm:4.1.9, @vitest/browser@npm:^4.1.0": - version: 4.1.9 - resolution: "@vitest/browser@npm:4.1.9" +"@vitest/browser@npm:4.1.10, @vitest/browser@npm:^4.1.0": + version: 4.1.10 + resolution: "@vitest/browser@npm:4.1.10" dependencies: "@blazediff/core": "npm:1.9.1" - "@vitest/mocker": "npm:4.1.9" - "@vitest/utils": "npm:4.1.9" + "@vitest/mocker": "npm:4.1.10" + "@vitest/utils": "npm:4.1.10" magic-string: "npm:^0.30.21" pngjs: "npm:^7.0.0" sirv: "npm:^3.0.2" tinyrainbow: "npm:^3.1.0" ws: "npm:^8.19.0" peerDependencies: - vitest: 4.1.9 - checksum: 10c0/9ff634e56b0bd5a3727f2eec7666f97a089b92fb596d5b9e4fbe2a2e4fd988aa7a7bd2a7b98d7cf14790717db69db8f72bae6dc007f2fac613b9bb93d8e8edfb + vitest: 4.1.10 + checksum: 10c0/61c86b8c0fcc78bd01de559914525e768dc16d565ee8a40a41d51ef6d99595c3c5e976defe32d090b3dda5f77a980caa11a2072e9c4d279cfb8d55d31c565fc7 languageName: node linkType: hard @@ -5305,25 +5305,25 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:4.1.9": - version: 4.1.9 - resolution: "@vitest/expect@npm:4.1.9" +"@vitest/expect@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/expect@npm:4.1.10" dependencies: "@standard-schema/spec": "npm:^1.1.0" "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.1.9" - "@vitest/utils": "npm:4.1.9" + "@vitest/spy": "npm:4.1.10" + "@vitest/utils": "npm:4.1.10" chai: "npm:^6.2.2" tinyrainbow: "npm:^3.1.0" - checksum: 10c0/243bacaed2cba5e0ea4ec7465662fcec465a358a0e06381e337fac49426aa67a73b104fbb9d65d8bccadfba8f70e27f57ffb897aacfa140f579a556367357875 + checksum: 10c0/a817ad0d9bd6a039776a7228d54fb8319c17e4af15917407f5566ac61781a8511f591d302519d6999217399915bc3c0290028189fc73f5c38f80cb01b6f19c8d languageName: node linkType: hard -"@vitest/mocker@npm:4.1.9": - version: 4.1.9 - resolution: "@vitest/mocker@npm:4.1.9" +"@vitest/mocker@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/mocker@npm:4.1.10" dependencies: - "@vitest/spy": "npm:4.1.9" + "@vitest/spy": "npm:4.1.10" estree-walker: "npm:^3.0.3" magic-string: "npm:^0.30.21" peerDependencies: @@ -5334,7 +5334,7 @@ __metadata: optional: true vite: optional: true - checksum: 10c0/707353b7435bbfd441cc754e4ee7bc5921b70d07b051c6e414b6bbe4ca369154702b0ddeb603389469fe87ca1983e002eb2d55044582661f54a1945dd27e5c82 + checksum: 10c0/4aa70b0df58681652e2e28093437fb2e8f4d02a6d03f5619abc266ac1c5ae5f43326148061d13ae6e071e0f6cfcf7634659af63644de8ce098a7c98949a3d1ad languageName: node linkType: hard @@ -5347,6 +5347,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/pretty-format@npm:4.1.10" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/1a5daba730ffe23f2000bff484b4b2842f3b178d93663cb487b215516b8d3b62caa3e2bb2a3c63307b61a9fe58fb9bfff38559bc0c5e49d8aa403d6803a1d918 + languageName: node + linkType: hard + "@vitest/pretty-format@npm:4.1.9": version: 4.1.9 resolution: "@vitest/pretty-format@npm:4.1.9" @@ -5356,25 +5365,25 @@ __metadata: languageName: node linkType: hard -"@vitest/runner@npm:4.1.9": - version: 4.1.9 - resolution: "@vitest/runner@npm:4.1.9" +"@vitest/runner@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/runner@npm:4.1.10" dependencies: - "@vitest/utils": "npm:4.1.9" + "@vitest/utils": "npm:4.1.10" pathe: "npm:^2.0.3" - checksum: 10c0/d206b4891a64b1f55c346f832b0a7b489108094d8ae34438d3b53e78be7b45b139fa95ffa027c98c357bd532268ee573168de1943235b7eed32a9236ed5978bb + checksum: 10c0/554b72639de9694271b99be8ae273fe12ec793093ec91cce143816cd1187d40b7138a4d9d4de4f456cfca9567de986825bff97e107c05b9eb4abc130e854286d languageName: node linkType: hard -"@vitest/snapshot@npm:4.1.9": - version: 4.1.9 - resolution: "@vitest/snapshot@npm:4.1.9" +"@vitest/snapshot@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/snapshot@npm:4.1.10" dependencies: - "@vitest/pretty-format": "npm:4.1.9" - "@vitest/utils": "npm:4.1.9" + "@vitest/pretty-format": "npm:4.1.10" + "@vitest/utils": "npm:4.1.10" magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10c0/c3099df12ad1f9c1e180441856c9eb82f1990f87ff16aafedd6fa19978eaff20bc59220b692a99fcc822daef86eab256ba3dadb49544b7bd625b57c49cd9d995 + checksum: 10c0/e71398725f51af5fd0c07bb4b957d0f987daf9b4c564ac24cb2a4d1afde1a6939f535ac17761a32dcc41b0a1e6d4088af66dc44df89fdebebb92aabed1a92b5f languageName: node linkType: hard @@ -5387,10 +5396,10 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:4.1.9": - version: 4.1.9 - resolution: "@vitest/spy@npm:4.1.9" - checksum: 10c0/e51f328f55b76e8ba66e5e18f183484a8dc0a092685b101112d3e9fb8e989ddca162c98ddf00254476502c25bc05c4ec1e277fd6ad8bfc702464c08f6b5dd115 +"@vitest/spy@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/spy@npm:4.1.10" + checksum: 10c0/e5c08012560af6727fd66741c5cda25560d7c5442103d0c83e4276a9b0dd90b9da6cdf823a461195229a16c6ff87768ce788a68d0fa29dea73ee285618668178 languageName: node linkType: hard @@ -5422,6 +5431,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/utils@npm:4.1.10" + dependencies: + "@vitest/pretty-format": "npm:4.1.10" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/05b0ecec6997ec22fc08377e57dbd8fa37992e05961f3a7a916d98b1ab56d15c2a87dbd83d392b628242bdc156b1705e7fa60a3bf0c54bdb51158c153e05fc5d + languageName: node + linkType: hard + "@vitest/utils@npm:4.1.9": version: 4.1.9 resolution: "@vitest/utils@npm:4.1.9" @@ -14585,17 +14605,17 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^4.1.7": - version: 4.1.9 - resolution: "vitest@npm:4.1.9" +"vitest@npm:^4.1.10": + version: 4.1.10 + resolution: "vitest@npm:4.1.10" dependencies: - "@vitest/expect": "npm:4.1.9" - "@vitest/mocker": "npm:4.1.9" - "@vitest/pretty-format": "npm:4.1.9" - "@vitest/runner": "npm:4.1.9" - "@vitest/snapshot": "npm:4.1.9" - "@vitest/spy": "npm:4.1.9" - "@vitest/utils": "npm:4.1.9" + "@vitest/expect": "npm:4.1.10" + "@vitest/mocker": "npm:4.1.10" + "@vitest/pretty-format": "npm:4.1.10" + "@vitest/runner": "npm:4.1.10" + "@vitest/snapshot": "npm:4.1.10" + "@vitest/spy": "npm:4.1.10" + "@vitest/utils": "npm:4.1.10" es-module-lexer: "npm:^2.0.0" expect-type: "npm:^1.3.0" magic-string: "npm:^0.30.21" @@ -14613,12 +14633,12 @@ __metadata: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.1.9 - "@vitest/browser-preview": 4.1.9 - "@vitest/browser-webdriverio": 4.1.9 - "@vitest/coverage-istanbul": 4.1.9 - "@vitest/coverage-v8": 4.1.9 - "@vitest/ui": 4.1.9 + "@vitest/browser-playwright": 4.1.10 + "@vitest/browser-preview": 4.1.10 + "@vitest/browser-webdriverio": 4.1.10 + "@vitest/coverage-istanbul": 4.1.10 + "@vitest/coverage-v8": 4.1.10 + "@vitest/ui": 4.1.10 happy-dom: "*" jsdom: "*" vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -14649,7 +14669,7 @@ __metadata: optional: false bin: vitest: ./vitest.mjs - checksum: 10c0/1ac80ef4991be82822a52aea48415f1bc64ddf8fd88ee24c172ec368f1d480fefacbde622c3c951982f7961a1d07313e18deaafc774d29e42ad6f6ffa63334a7 + checksum: 10c0/ff07294a57f9c62f3b503f7cf88a52ee0753ed26389a49cda430387a3898f39d80af47180b0af19e27acab5bd11ae95706bd4b44ce8befc97d3ae49af6ca4fc1 languageName: node linkType: hard