diff --git a/.nvmrc b/.nvmrc index f88da62e24..b0195acf78 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.11 +24.12 diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d66f0fb11a..32d4bc1867 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -11,6 +11,11 @@ import type { Preview } from '@storybook/react-vite'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { action } from 'storybook/actions'; +import { + importCustomEmojiData, + importLegacyShortcodes, + importEmojiData, +} from '@/mastodon/features/emoji/loader'; import type { LocaleData } from '@/mastodon/locales'; import { reducerWithInitialState } from '@/mastodon/reducers'; import { defaultMiddleware } from '@/mastodon/store/store'; @@ -127,7 +132,12 @@ const preview: Preview = { ), ], - loaders: [mswLoader], + loaders: [ + mswLoader, + importCustomEmojiData, + importLegacyShortcodes, + ({ globals: { locale } }) => importEmojiData(locale as string), + ], parameters: { layout: 'centered', diff --git a/Gemfile.lock b/Gemfile.lock index 6b02122c97..c40b45088d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -773,7 +773,7 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.33.4) + rubocop-rails (2.34.2) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -792,7 +792,7 @@ GEM ruby-saml (1.18.1) nokogiri (>= 1.13.10) rexml - ruby-vips (2.2.5) + ruby-vips (2.3.0) ffi (~> 1.12) logger rubyzip (3.2.2) diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 49cfc8ad1c..1f7abb97fa 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -39,7 +39,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController return @body if defined?(@body) @body = request.body.read - @body.force_encoding('UTF-8') if @body.present? + @body.presence&.force_encoding('UTF-8') request.body.rewind if request.body.respond_to?(:rewind) diff --git a/app/controllers/api/v1/statuses/quotes_controller.rb b/app/controllers/api/v1/statuses/quotes_controller.rb index be3a4edc83..d851e55c29 100644 --- a/app/controllers/api/v1/statuses/quotes_controller.rb +++ b/app/controllers/api/v1/statuses/quotes_controller.rb @@ -41,8 +41,8 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController if current_account&.id != @status.account_id domains = @statuses.filter_map(&:account_domain).uniq account_ids = @statuses.map(&:account_id).uniq - relations = current_account&.relations_map(account_ids, domains) || {} - @statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? } + current_account&.preload_relations!(account_ids, domains) + @statuses.reject! { |status| StatusFilter.new(status, current_account).filtered? } end end diff --git a/app/controllers/api/v1_alpha/collection_items_controller.rb b/app/controllers/api/v1_alpha/collection_items_controller.rb index cc2e5cdef1..21699a5b6f 100644 --- a/app/controllers/api/v1_alpha/collection_items_controller.rb +++ b/app/controllers/api/v1_alpha/collection_items_controller.rb @@ -11,6 +11,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController before_action :set_collection before_action :set_account, only: [:create] + before_action :set_collection_item, only: [:destroy] after_action :verify_authorized @@ -23,6 +24,14 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController render json: @item, serializer: REST::CollectionItemSerializer end + def destroy + authorize @collection, :update? + + @collection_item.destroy + + head 200 + end + private def set_collection @@ -35,6 +44,10 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController @account = Account.find(params[:account_id]) end + def set_collection_item + @collection_item = @collection.collection_items.find(params[:id]) + end + def check_feature_enabled raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? end diff --git a/app/controllers/wrapstodon_controller.rb b/app/controllers/wrapstodon_controller.rb index 74f0dbb65a..b1fe521fb1 100644 --- a/app/controllers/wrapstodon_controller.rb +++ b/app/controllers/wrapstodon_controller.rb @@ -12,7 +12,7 @@ class WrapstodonController < ApplicationController skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode? def show - expires_in 10.seconds, public: true if current_account.nil? + expires_in 10.minutes, public: true if current_account.nil? end private diff --git a/app/helpers/database_helper.rb b/app/helpers/database_helper.rb index 62a26a0c2a..f245d303d1 100644 --- a/app/helpers/database_helper.rb +++ b/app/helpers/database_helper.rb @@ -2,7 +2,7 @@ module DatabaseHelper def replica_enabled? - ENV['REPLICA_DB_NAME'] || ENV.fetch('REPLICA_DATABASE_URL', nil) + ENV['REPLICA_DB_NAME'] || ENV['REPLICA_DB_HOST'] || ENV.fetch('REPLICA_DATABASE_URL', nil) end module_function :replica_enabled? diff --git a/app/helpers/wrapstodon_helper.rb b/app/helpers/wrapstodon_helper.rb index da3b0d6fad..8031c51179 100644 --- a/app/helpers/wrapstodon_helper.rb +++ b/app/helpers/wrapstodon_helper.rb @@ -2,15 +2,19 @@ module WrapstodonHelper def render_wrapstodon_share_data(report) - json = ActiveModelSerializers::SerializableResource.new( + payload = ActiveModelSerializers::SerializableResource.new( AnnualReportsPresenter.new([report]), serializer: REST::AnnualReportsSerializer, scope: nil, scope_name: :current_user - ).to_json + ).as_json + + payload[:me] = current_account.id.to_s if user_signed_in? + + json_string = payload.to_json # rubocop:disable Rails/OutputSafety - content_tag(:script, json_escape(json).html_safe, type: 'application/json', id: 'wrapstodon-data') + content_tag(:script, json_escape(json_string).html_safe, type: 'application/json', id: 'wrapstodon-data') # rubocop:enable Rails/OutputSafety end end diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx index a2c53d472b..92b9d1d917 100644 --- a/app/javascript/entrypoints/admin.tsx +++ b/app/javascript/entrypoints/admin.tsx @@ -256,9 +256,8 @@ async function mountReactComponent(element: Element) { const componentProps = JSON.parse(stringProps) as object; - const { default: AdminComponent } = await import( - '@/mastodon/containers/admin_component' - ); + const { default: AdminComponent } = + await import('@/mastodon/containers/admin_component'); const { default: Component } = (await import( `@/mastodon/components/admin/${componentName}.jsx` diff --git a/app/javascript/entrypoints/wrapstodon.tsx b/app/javascript/entrypoints/wrapstodon.tsx index 7a74e18d52..e2c8d5a38e 100644 --- a/app/javascript/entrypoints/wrapstodon.tsx +++ b/app/javascript/entrypoints/wrapstodon.tsx @@ -25,7 +25,7 @@ function loaded() { const initialState = JSON.parse( propsNode.textContent, - ) as ApiAnnualReportResponse; + ) as ApiAnnualReportResponse & { me?: string }; const report = initialState.annual_reports[0]; if (!report) { @@ -35,7 +35,10 @@ function loaded() { // Set up store store.dispatch( hydrateStore({ - meta: { locale: document.documentElement.lang }, + meta: { + locale: document.documentElement.lang, + me: initialState.me, + }, accounts: initialState.accounts, }), ); diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index 533e990368..d698a1a699 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -102,8 +102,7 @@ export interface ApiAccountWarningJSON { appeal: unknown; } -interface ModerationWarningNotificationGroupJSON - extends BaseNotificationGroupJSON { +interface ModerationWarningNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'moderation_warning'; moderation_warning: ApiAccountWarningJSON; } @@ -123,14 +122,12 @@ export interface ApiAccountRelationshipSeveranceEventJSON { created_at: string; } -interface AccountRelationshipSeveranceNotificationGroupJSON - extends BaseNotificationGroupJSON { +interface AccountRelationshipSeveranceNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'severed_relationships'; event: ApiAccountRelationshipSeveranceEventJSON; } -interface AccountRelationshipSeveranceNotificationJSON - extends BaseNotificationJSON { +interface AccountRelationshipSeveranceNotificationJSON extends BaseNotificationJSON { type: 'severed_relationships'; event: ApiAccountRelationshipSeveranceEventJSON; } diff --git a/app/javascript/mastodon/components/button/index.tsx b/app/javascript/mastodon/components/button/index.tsx index ca2da05b70..a75449b0d5 100644 --- a/app/javascript/mastodon/components/button/index.tsx +++ b/app/javascript/mastodon/components/button/index.tsx @@ -5,8 +5,10 @@ import classNames from 'classnames'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -interface BaseProps - extends Omit, 'children'> { +interface BaseProps extends Omit< + React.ButtonHTMLAttributes, + 'children' +> { block?: boolean; secondary?: boolean; plain?: boolean; diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index dacbc33789..fc20ff53fa 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -309,7 +309,7 @@ interface DropdownProps { renderItem?: RenderItemFn; renderHeader?: RenderHeaderFn; onOpen?: // Must use a union type for the full function as a union with void is not allowed. - | ((event: React.MouseEvent | React.KeyboardEvent) => void) + | ((event: React.MouseEvent | React.KeyboardEvent) => void) | ((event: React.MouseEvent | React.KeyboardEvent) => boolean); onItemClick?: ItemClickFn; } diff --git a/app/javascript/mastodon/components/emoji/emoji.stories.tsx b/app/javascript/mastodon/components/emoji/emoji.stories.tsx index a5e283158d..d390387a03 100644 --- a/app/javascript/mastodon/components/emoji/emoji.stories.tsx +++ b/app/javascript/mastodon/components/emoji/emoji.stories.tsx @@ -2,8 +2,6 @@ import type { ComponentProps } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { importCustomEmojiData } from '@/mastodon/features/emoji/loader'; - import { Emoji } from './index'; type EmojiProps = ComponentProps & { state: string }; @@ -38,7 +36,6 @@ const meta = { }, }, render(args) { - void importCustomEmojiData(); return ; }, } satisfies Meta; @@ -54,3 +51,9 @@ export const CustomEmoji: Story = { code: ':custom:', }, }; + +export const LegacyEmoji: Story = { + args: { + code: ':copyright:', + }, +}; diff --git a/app/javascript/mastodon/components/emoji/index.tsx b/app/javascript/mastodon/components/emoji/index.tsx index 8a27b3f1e8..0b1ba7fef3 100644 --- a/app/javascript/mastodon/components/emoji/index.tsx +++ b/app/javascript/mastodon/components/emoji/index.tsx @@ -3,7 +3,10 @@ import { useContext, useEffect, useState } from 'react'; import classNames from 'classnames'; -import { EMOJI_TYPE_CUSTOM } from '@/mastodon/features/emoji/constants'; +import { + EMOJI_TYPE_CUSTOM, + EMOJI_TYPE_UNICODE, +} from '@/mastodon/features/emoji/constants'; import { useEmojiAppState } from '@/mastodon/features/emoji/mode'; import { emojiToInversionClassName, @@ -47,8 +50,6 @@ export const Emoji: FC = ({ const animate = useContext(AnimateEmojiContext); - const inversionClass = emojiToInversionClassName(code); - const fallback = showFallback ? code : null; // If the code is invalid or we otherwise know it's not valid, show the fallback. @@ -56,10 +57,6 @@ export const Emoji: FC = ({ return fallback; } - if (!shouldRenderImage(state, appState.mode)) { - return code; - } - if (!isStateLoaded(state)) { if (showLoading) { return ; @@ -67,6 +64,17 @@ export const Emoji: FC = ({ return fallback; } + const inversionClass = + state.type === EMOJI_TYPE_UNICODE && + emojiToInversionClassName(state.data.unicode); + + if (!shouldRenderImage(state, appState.mode)) { + if (state.type === EMOJI_TYPE_UNICODE) { + return state.data.unicode; + } + return code; + } + if (state.type === EMOJI_TYPE_CUSTOM) { const shortcode = `:${state.code}:`; return ( diff --git a/app/javascript/mastodon/features/annual_report/archetype.tsx b/app/javascript/mastodon/features/annual_report/archetype.tsx index 465944df54..7d1cf0bdd4 100644 --- a/app/javascript/mastodon/features/annual_report/archetype.tsx +++ b/app/javascript/mastodon/features/annual_report/archetype.tsx @@ -12,6 +12,7 @@ import replier from '@/images/archetypes/replier.png'; import space_elements from '@/images/archetypes/space_elements.png'; import { Avatar } from '@/mastodon/components/avatar'; import { Button } from '@/mastodon/components/button'; +import { DisplayName } from '@/mastodon/components/display_name'; import { me } from '@/mastodon/initial_state'; import type { Account } from '@/mastodon/models/account'; import type { @@ -137,9 +138,6 @@ export const Archetype: React.FC<{ ? archetypeSelfDescriptions : archetypePublicDescriptions; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we specifically want to fallback if `display_name` is empty - const name = account?.display_name || account?.username; - return (
, + }} /> )} @@ -199,7 +199,7 @@ export const Archetype: React.FC<{

{isRevealed ? ( intl.formatMessage(descriptions[archetype], { - name, + name: , }) ) : ( state.accounts, (state) => state.annualReport.report], (accounts, report) => { - if (me) { - return accounts.get(me); - } if (report?.schema_version === 2) { return accounts.get(report.account_id); } @@ -109,7 +105,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({ {topHashtag && ( )} diff --git a/app/javascript/mastodon/features/annual_report/most_used_hashtag.tsx b/app/javascript/mastodon/features/annual_report/most_used_hashtag.tsx index 5fe386bf2b..293146cc2a 100644 --- a/app/javascript/mastodon/features/annual_report/most_used_hashtag.tsx +++ b/app/javascript/mastodon/features/annual_report/most_used_hashtag.tsx @@ -2,15 +2,17 @@ import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import { DisplayName } from '@/mastodon/components/display_name'; +import type { Account } from '@/mastodon/models/account'; import type { NameAndCount } from 'mastodon/models/annual_report'; import styles from './index.module.scss'; export const MostUsedHashtag: React.FC<{ hashtag: NameAndCount; - name: string | undefined; context: 'modal' | 'standalone'; -}> = ({ hashtag, name, context }) => { + account?: Account; +}> = ({ hashtag, context, account }) => { return (

#{hashtag.name}

- {context === 'modal' ? ( + {context === 'modal' && ( - ) : ( - name && ( - - ) + )} + {context !== 'modal' && account && ( + , + }} + /> )}

diff --git a/app/javascript/mastodon/features/annual_report/shared_page.tsx b/app/javascript/mastodon/features/annual_report/shared_page.tsx index c69a05e5af..f2b26bf2aa 100644 --- a/app/javascript/mastodon/features/annual_report/shared_page.tsx +++ b/app/javascript/mastodon/features/annual_report/shared_page.tsx @@ -3,12 +3,13 @@ import type { FC } from 'react'; import { FormattedMessage } from 'react-intl'; import { IconLogo } from '@/mastodon/components/logo'; -import { me } from '@/mastodon/initial_state'; +import { useAppSelector } from '@/mastodon/store'; import { AnnualReport } from './index'; import classes from './shared_page.module.scss'; export const WrapstodonSharedPage: FC = () => { + const isLoggedIn = useAppSelector((state) => !!state.meta.get('me')); return (
@@ -23,7 +24,7 @@ export const WrapstodonSharedPage: FC = () => { - {!me && ( + {!isLoggedIn && ( { } return ( -
+ <>
)} - + ); }; diff --git a/app/javascript/mastodon/features/emoji/constants.ts b/app/javascript/mastodon/features/emoji/constants.ts index f770573121..e02663c9d8 100644 --- a/app/javascript/mastodon/features/emoji/constants.ts +++ b/app/javascript/mastodon/features/emoji/constants.ts @@ -23,6 +23,10 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji'; export const EMOJI_TYPE_UNICODE = 'unicode'; export const EMOJI_TYPE_CUSTOM = 'custom'; +export const EMOJI_DB_NAME_SHORTCODES = 'shortcodes'; + +export const EMOJI_DB_SHORTCODE_TEST = '2122'; // 2122 is the trademark sign, which we know has shortcodes in all datasets. + export const EMOJIS_WITH_DARK_BORDER = [ '๐ŸŽฑ', // 1F3B1 '๐Ÿœ', // 1F41C diff --git a/app/javascript/mastodon/features/emoji/database.test.ts b/app/javascript/mastodon/features/emoji/database.test.ts index 0689fd7c54..5931a238ea 100644 --- a/app/javascript/mastodon/features/emoji/database.test.ts +++ b/app/javascript/mastodon/features/emoji/database.test.ts @@ -1,7 +1,8 @@ import { IDBFactory } from 'fake-indexeddb'; -import { unicodeEmojiFactory } from '@/testing/factories'; +import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories'; +import { EMOJI_DB_SHORTCODE_TEST } from './constants'; import { putEmojiData, loadEmojiByHexcode, @@ -9,6 +10,11 @@ import { searchEmojisByTag, testClear, testGet, + putCustomEmojiData, + putLegacyShortcodes, + loadLegacyShortcodesByShortcode, + loadLatestEtag, + putLatestEtag, } from './database'; describe('emoji database', () => { @@ -16,6 +22,7 @@ describe('emoji database', () => { testClear(); indexedDB = new IDBFactory(); }); + describe('putEmojiData', () => { test('adds to loaded locales', async () => { const { loadedLocales } = await testGet(); @@ -33,6 +40,29 @@ describe('emoji database', () => { }); }); + describe('putCustomEmojiData', () => { + test('loads custom emoji into indexedDB', async () => { + const { db } = await testGet(); + await putCustomEmojiData([customEmojiFactory()]); + await expect(db.get('custom', 'custom')).resolves.toEqual( + customEmojiFactory(), + ); + }); + }); + + describe('putLegacyShortcodes', () => { + test('loads shortcodes into indexedDB', async () => { + const { db } = await testGet(); + await putLegacyShortcodes({ + test_hexcode: ['shortcode1', 'shortcode2'], + }); + await expect(db.get('shortcodes', 'test_hexcode')).resolves.toEqual({ + hexcode: 'test_hexcode', + shortcodes: ['shortcode1', 'shortcode2'], + }); + }); + }); + describe('loadEmojiByHexcode', () => { test('throws if the locale is not loaded', async () => { await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError( @@ -136,4 +166,58 @@ describe('emoji database', () => { expect(actual).toHaveLength(0); }); }); + + describe('loadLegacyShortcodesByShortcode', () => { + const data = { + hexcode: 'test_hexcode', + shortcodes: ['shortcode1', 'shortcode2'], + }; + + beforeEach(async () => { + await putLegacyShortcodes({ + [data.hexcode]: data.shortcodes, + }); + }); + + test('retrieves the shortcodes', async () => { + await expect( + loadLegacyShortcodesByShortcode('shortcode1'), + ).resolves.toEqual(data); + await expect( + loadLegacyShortcodesByShortcode('shortcode2'), + ).resolves.toEqual(data); + }); + }); + + describe('loadLatestEtag', () => { + beforeEach(async () => { + await putLatestEtag('etag', 'en'); + await putEmojiData([unicodeEmojiFactory()], 'en'); + await putLatestEtag('fr-etag', 'fr'); + }); + + test('retrieves the etag for loaded locale', async () => { + await putEmojiData( + [unicodeEmojiFactory({ hexcode: EMOJI_DB_SHORTCODE_TEST })], + 'en', + ); + const etag = await loadLatestEtag('en'); + expect(etag).toBe('etag'); + }); + + test('returns null if locale has no shortcodes', async () => { + const etag = await loadLatestEtag('en'); + expect(etag).toBeNull(); + }); + + test('returns null if locale not loaded', async () => { + const etag = await loadLatestEtag('de'); + expect(etag).toBeNull(); + }); + + test('returns null if locale has no data', async () => { + const etag = await loadLatestEtag('fr'); + expect(etag).toBeNull(); + }); + }); }); diff --git a/app/javascript/mastodon/features/emoji/database.ts b/app/javascript/mastodon/features/emoji/database.ts index b7f8a32f76..2e8de71221 100644 --- a/app/javascript/mastodon/features/emoji/database.ts +++ b/app/javascript/mastodon/features/emoji/database.ts @@ -1,8 +1,9 @@ import { SUPPORTED_LOCALES } from 'emojibase'; -import type { Locale } from 'emojibase'; +import type { Locale, ShortcodesDataset } from 'emojibase'; import type { DBSchema, IDBPDatabase } from 'idb'; import { openDB } from 'idb'; +import { EMOJI_DB_SHORTCODE_TEST } from './constants'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import type { CustomEmojiData, @@ -19,6 +20,17 @@ interface EmojiDB extends LocaleTables, DBSchema { category: string; }; }; + shortcodes: { + key: string; + value: { + hexcode: string; + shortcodes: string[]; + }; + indexes: { + hexcode: string; + shortcodes: string[]; + }; + }; etags: { key: LocaleOrCustom; value: string; @@ -33,13 +45,14 @@ interface LocaleTable { label: string; order: number; tags: string[]; + shortcodes: string[]; }; } type LocaleTables = Record; type Database = IDBPDatabase; -const SCHEMA_VERSION = 1; +const SCHEMA_VERSION = 2; const loadedLocales = new Set(); @@ -52,28 +65,76 @@ const loadDB = (() => { // Actually load the DB. async function initDB() { const db = await openDB('mastodon-emoji', SCHEMA_VERSION, { - upgrade(database) { - const customTable = database.createObjectStore('custom', { - keyPath: 'shortcode', - autoIncrement: false, - }); - customTable.createIndex('category', 'category'); + upgrade(database, oldVersion, newVersion, trx) { + if (!database.objectStoreNames.contains('custom')) { + const customTable = database.createObjectStore('custom', { + keyPath: 'shortcode', + autoIncrement: false, + }); + customTable.createIndex('category', 'category'); + } - database.createObjectStore('etags'); + if (!database.objectStoreNames.contains('etags')) { + database.createObjectStore('etags'); + } for (const locale of SUPPORTED_LOCALES) { - const localeTable = database.createObjectStore(locale, { + if (!database.objectStoreNames.contains(locale)) { + const localeTable = database.createObjectStore(locale, { + keyPath: 'hexcode', + autoIncrement: false, + }); + localeTable.createIndex('group', 'group'); + localeTable.createIndex('label', 'label'); + localeTable.createIndex('order', 'order'); + localeTable.createIndex('tags', 'tags', { multiEntry: true }); + localeTable.createIndex('shortcodes', 'shortcodes', { + multiEntry: true, + }); + } + // Added in version 2. + const localeTable = trx.objectStore(locale); + if (!localeTable.indexNames.contains('shortcodes')) { + localeTable.createIndex('shortcodes', 'shortcodes', { + multiEntry: true, + }); + } + } + + if (!database.objectStoreNames.contains('shortcodes')) { + const shortcodeTable = database.createObjectStore('shortcodes', { keyPath: 'hexcode', autoIncrement: false, }); - localeTable.createIndex('group', 'group'); - localeTable.createIndex('label', 'label'); - localeTable.createIndex('order', 'order'); - localeTable.createIndex('tags', 'tags', { multiEntry: true }); + shortcodeTable.createIndex('hexcode', 'hexcode'); + shortcodeTable.createIndex('shortcodes', 'shortcodes', { + multiEntry: true, + }); } + + log( + 'Upgraded emoji database from version %d to %d', + oldVersion, + newVersion, + ); + }, + blocked(currentVersion, blockedVersion) { + log( + 'Emoji database upgrade from version %d to %d is blocked', + currentVersion, + blockedVersion, + ); + }, + blocking(currentVersion, blockedVersion) { + log( + 'Emoji database upgrade from version %d is blocking upgrade to %d', + currentVersion, + blockedVersion, + ); }, }); await syncLocales(db); + log('Loaded database version %d', db.version); return db; } @@ -107,6 +168,20 @@ export async function putCustomEmojiData(emojis: CustomEmojiData[]) { await trx.done; } +export async function putLegacyShortcodes(shortcodes: ShortcodesDataset) { + const db = await loadDB(); + const trx = db.transaction('shortcodes', 'readwrite'); + await Promise.all( + Object.entries(shortcodes).map(([hexcode, codes]) => + trx.store.put({ + hexcode, + shortcodes: Array.isArray(codes) ? codes : [codes], + }), + ), + ); + await trx.done; +} + export async function putLatestEtag(etag: string, localeString: string) { const locale = toSupportedLocaleOrCustom(localeString); const db = await loadDB(); @@ -161,6 +236,15 @@ export async function searchCustomEmojisByShortcodes(shortcodes: string[]) { return results.filter((emoji) => shortcodes.includes(emoji.shortcode)); } +export async function loadLegacyShortcodesByShortcode(shortcode: string) { + const db = await loadDB(); + return db.getFromIndex( + 'shortcodes', + 'shortcodes', + IDBKeyRange.only(shortcode), + ); +} + export async function loadLatestEtag(localeString: string) { const locale = toSupportedLocaleOrCustom(localeString); const db = await loadDB(); @@ -168,6 +252,15 @@ export async function loadLatestEtag(localeString: string) { if (!rowCount) { return null; // No data for this locale, return null even if there is an etag. } + + // Check if shortcodes exist for the given Unicode locale. + if (locale !== 'custom') { + const result = await db.get(locale, EMOJI_DB_SHORTCODE_TEST); + if (!result?.shortcodes) { + return null; + } + } + const etag = await db.get('etags', locale); return etag ?? null; } diff --git a/app/javascript/mastodon/features/emoji/index.ts b/app/javascript/mastodon/features/emoji/index.ts index 4b0f79133c..bd38dea77a 100644 --- a/app/javascript/mastodon/features/emoji/index.ts +++ b/app/javascript/mastodon/features/emoji/index.ts @@ -1,5 +1,9 @@ +import type { Locale } from 'emojibase'; + import { initialState } from '@/mastodon/initial_state'; +import type { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants'; +import { importLegacyShortcodes, localeToShortcodesPath } from './loader'; import { toSupportedLocale } from './locale'; import type { LocaleOrCustom } from './types'; import { emojiLogger } from './utils'; @@ -36,12 +40,8 @@ export function initializeEmoji() { log('worker ready, loading data'); clearTimeout(timeoutId); messageWorker('custom'); + messageWorker('shortcodes'); void loadEmojiLocale(userLocale); - // Load English locale as well, because people are still used to - // using it from before we supported other locales. - if (userLocale !== 'en') { - void loadEmojiLocale('en'); - } } else { log('got worker message: %s', message); } @@ -58,20 +58,23 @@ async function fallbackLoad() { if (emojis) { log('loaded %d custom emojis', emojis.length); } - await loadEmojiLocale(userLocale); - if (userLocale !== 'en') { - await loadEmojiLocale('en'); + const shortcodes = await importLegacyShortcodes(); + if (shortcodes.length) { + log('loaded %d legacy shortcodes', shortcodes.length); } + await loadEmojiLocale(userLocale); } async function loadEmojiLocale(localeString: string) { const locale = toSupportedLocale(localeString); - const { importEmojiData, localeToPath } = await import('./loader'); + const { importEmojiData, localeToEmojiPath: localeToPath } = + await import('./loader'); if (worker) { const path = await localeToPath(locale); + const shortcodesPath = await localeToShortcodesPath(locale); log('asking worker to load locale %s from %s', locale, path); - messageWorker(locale, path); + messageWorker(locale, path, shortcodesPath); } else { const emojis = await importEmojiData(locale); if (emojis) { @@ -80,9 +83,17 @@ async function loadEmojiLocale(localeString: string) { } } -function messageWorker(locale: LocaleOrCustom, path?: string) { +function messageWorker( + locale: typeof EMOJI_TYPE_CUSTOM | typeof EMOJI_DB_NAME_SHORTCODES, +): void; +function messageWorker(locale: Locale, path: string, shortcodes?: string): void; +function messageWorker( + locale: LocaleOrCustom | typeof EMOJI_DB_NAME_SHORTCODES, + path?: string, + shortcodes?: string, +) { if (!worker) { return; } - worker.postMessage({ locale, path }); + worker.postMessage({ locale, path, shortcodes }); } diff --git a/app/javascript/mastodon/features/emoji/loader.ts b/app/javascript/mastodon/features/emoji/loader.ts index 7251559d6b..b2407697df 100644 --- a/app/javascript/mastodon/features/emoji/loader.ts +++ b/app/javascript/mastodon/features/emoji/loader.ts @@ -1,16 +1,26 @@ import { flattenEmojiData } from 'emojibase'; -import type { CompactEmoji, FlatCompactEmoji, Locale } from 'emojibase'; +import type { + CompactEmoji, + FlatCompactEmoji, + Locale, + ShortcodesDataset, +} from 'emojibase'; import { putEmojiData, putCustomEmojiData, loadLatestEtag, putLatestEtag, + putLegacyShortcodes, } from './database'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import type { CustomEmojiData } from './types'; -export async function importEmojiData(localeString: string, path?: string) { +export async function importEmojiData( + localeString: string, + path?: string, + shortcodes: boolean | string = true, +) { const locale = toSupportedLocale(localeString); // Validate the provided path. @@ -18,14 +28,42 @@ export async function importEmojiData(localeString: string, path?: string) { throw new Error('Invalid path for emoji data'); } else { // Otherwise get the path if not provided. - path ??= await localeToPath(locale); + path ??= await localeToEmojiPath(locale); } const emojis = await fetchAndCheckEtag(locale, path); if (!emojis) { return; } - const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis); + + const shortcodesData: ShortcodesDataset[] = []; + if (shortcodes) { + if ( + typeof shortcodes === 'string' && + !/^[/a-z]*\/packs\/assets\/shortcodes\/cldr\.json$/.test(shortcodes) + ) { + throw new Error('Invalid path for shortcodes data'); + } + const shortcodesPath = + typeof shortcodes === 'string' + ? shortcodes + : await localeToShortcodesPath(locale); + const shortcodesResponse = await fetchAndCheckEtag( + locale, + shortcodesPath, + false, + ); + if (shortcodesResponse) { + shortcodesData.push(shortcodesResponse); + } else { + throw new Error(`No shortcodes data found for locale ${locale}`); + } + } + + const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData( + emojis, + shortcodesData, + ); await putEmojiData(flattenedEmojis, locale); return flattenedEmojis; } @@ -42,32 +80,77 @@ export async function importCustomEmojiData() { return emojis; } -const modules = import.meta.glob( - '../../../../../node_modules/emojibase-data/**/compact.json', - { - query: '?url', - import: 'default', - }, -); - -export function localeToPath(locale: Locale) { - const key = `../../../../../node_modules/emojibase-data/${locale}/compact.json`; - if (!modules[key] || typeof modules[key] !== 'function') { - throw new Error(`Unsupported locale: ${locale}`); +export async function importLegacyShortcodes() { + const { default: shortcodesPath } = + await import('emojibase-data/en/shortcodes/iamcal.json?url'); + const response = await fetch(shortcodesPath); + if (!response.ok) { + throw new Error( + `Failed to fetch legacy shortcodes data: ${response.statusText}`, + ); } - return modules[key](); + const shortcodesData = (await response.json()) as ShortcodesDataset; + await putLegacyShortcodes(shortcodesData); + return Object.keys(shortcodesData); } -export async function fetchAndCheckEtag( +const emojiModules = new Map( + Object.entries( + import.meta.glob( + '../../../../../node_modules/emojibase-data/**/compact.json', + { + query: '?url', + import: 'default', + }, + ), + ).map(([key, loader]) => { + const match = /emojibase-data\/([^/]+)\/compact\.json$/.exec(key); + return [match?.at(1) ?? key, loader]; + }), +); + +export function localeToEmojiPath(locale: Locale) { + const path = emojiModules.get(locale); + if (!path) { + throw new Error(`Unsupported locale: ${locale}`); + } + return path(); +} + +const shortcodesModules = new Map( + Object.entries( + import.meta.glob( + '../../../../../node_modules/emojibase-data/**/shortcodes/cldr.json', + { + query: '?url', + import: 'default', + }, + ), + ).map(([key, loader]) => { + const match = /emojibase-data\/([^/]+)\/shortcodes\/cldr\.json$/.exec(key); + return [match?.at(1) ?? key, loader]; + }), +); + +export function localeToShortcodesPath(locale: Locale) { + const path = shortcodesModules.get(locale); + if (!path) { + throw new Error(`Unsupported locale for shortcodes: ${locale}`); + } + return path(); +} + +export async function fetchAndCheckEtag( localeString: string, path: string, + checkEtag = true, ): Promise { const locale = toSupportedLocaleOrCustom(localeString); // Use location.origin as this script may be loaded from a CDN domain. const url = new URL(path, location.origin); - const oldEtag = await loadLatestEtag(locale); + const oldEtag = checkEtag ? await loadLatestEtag(locale) : null; const response = await fetch(url, { headers: { 'Content-Type': 'application/json', @@ -85,13 +168,10 @@ export async function fetchAndCheckEtag( } const data = (await response.json()) as ResultType; - if (!Array.isArray(data)) { - throw new Error(`Unexpected data format for ${locale}: expected an array`); - } // Store the ETag for future requests const etag = response.headers.get('ETag'); - if (etag) { + if (etag && checkEtag) { await putLatestEtag(etag, localeString); } diff --git a/app/javascript/mastodon/features/emoji/render.test.ts b/app/javascript/mastodon/features/emoji/render.test.ts index 3c96cbfb55..782148b36e 100644 --- a/app/javascript/mastodon/features/emoji/render.test.ts +++ b/app/javascript/mastodon/features/emoji/render.test.ts @@ -7,6 +7,7 @@ import { stringToEmojiState, tokenizeText, } from './render'; +import type { EmojiStateCustom, EmojiStateUnicode } from './types'; describe('tokenizeText', () => { test('returns an array of text to be a single token', () => { @@ -120,13 +121,24 @@ describe('loadEmojiDataToState', () => { const dbCall = vi .spyOn(db, 'loadEmojiByHexcode') .mockResolvedValue(unicodeEmojiFactory()); - const unicodeState = { type: 'unicode', code: '1F60A' } as const; + const dbLegacyCall = vi + .spyOn(db, 'loadLegacyShortcodesByShortcode') + .mockResolvedValueOnce({ + shortcodes: ['legacy_code'], + hexcode: '1F60A', + }); + const unicodeState = { + type: 'unicode', + code: '1F60A', + } as const satisfies EmojiStateUnicode; const result = await loadEmojiDataToState(unicodeState, 'en'); expect(dbCall).toHaveBeenCalledWith('1F60A', 'en'); + expect(dbLegacyCall).toHaveBeenCalledWith('1F60A'); expect(result).toEqual({ type: 'unicode', code: '1F60A', data: unicodeEmojiFactory(), + shortcode: 'legacy_code', }); }); @@ -134,7 +146,10 @@ describe('loadEmojiDataToState', () => { const dbCall = vi .spyOn(db, 'loadCustomEmojiByShortcode') .mockResolvedValueOnce(customEmojiFactory()); - const customState = { type: 'custom', code: 'smile' } as const; + const customState = { + type: 'custom', + code: 'smile', + } as const satisfies EmojiStateCustom; const result = await loadEmojiDataToState(customState, 'en'); expect(dbCall).toHaveBeenCalledWith('smile'); expect(result).toEqual({ @@ -144,16 +159,47 @@ describe('loadEmojiDataToState', () => { }); }); + test('loads unicode data using legacy shortcode', async () => { + const dbLegacyCall = vi + .spyOn(db, 'loadLegacyShortcodesByShortcode') + .mockResolvedValueOnce({ + shortcodes: ['test'], + hexcode: 'test', + }); + const dbUnicodeCall = vi + .spyOn(db, 'loadEmojiByHexcode') + .mockResolvedValue(unicodeEmojiFactory()); + const unicodeState = { + type: 'unicode', + code: 'test', + } as const satisfies EmojiStateUnicode; + const result = await loadEmojiDataToState(unicodeState, 'en'); + expect(dbLegacyCall).toHaveBeenCalledWith('test'); + expect(dbUnicodeCall).toHaveBeenCalledWith('test', 'en'); + expect(result).toEqual({ + type: 'unicode', + code: 'test', + data: unicodeEmojiFactory(), + shortcode: 'test', + }); + }); + test('returns null if unicode emoji not found in database', async () => { vi.spyOn(db, 'loadEmojiByHexcode').mockResolvedValueOnce(undefined); - const unicodeState = { type: 'unicode', code: '1F60A' } as const; + const unicodeState = { + type: 'unicode', + code: '1F60A', + } as const satisfies EmojiStateUnicode; const result = await loadEmojiDataToState(unicodeState, 'en'); expect(result).toBeNull(); }); test('returns null if custom emoji not found in database', async () => { vi.spyOn(db, 'loadCustomEmojiByShortcode').mockResolvedValueOnce(undefined); - const customState = { type: 'custom', code: 'smile' } as const; + const customState = { + type: 'custom', + code: 'smile', + } as const satisfies EmojiStateCustom; const result = await loadEmojiDataToState(customState, 'en'); expect(result).toBeNull(); }); @@ -167,7 +213,10 @@ describe('loadEmojiDataToState', () => { .spyOn(console, 'warn') .mockImplementationOnce(() => null); - const unicodeState = { type: 'unicode', code: '1F60A' } as const; + const unicodeState = { + type: 'unicode', + code: '1F60A', + } as const satisfies EmojiStateUnicode; const result = await loadEmojiDataToState(unicodeState, 'en'); expect(dbCall).toHaveBeenCalledTimes(2); diff --git a/app/javascript/mastodon/features/emoji/render.ts b/app/javascript/mastodon/features/emoji/render.ts index 574d5ef59b..e00525fe0a 100644 --- a/app/javascript/mastodon/features/emoji/render.ts +++ b/app/javascript/mastodon/features/emoji/render.ts @@ -7,6 +7,7 @@ import { import { loadCustomEmojiByShortcode, loadEmojiByHexcode, + loadLegacyShortcodesByShortcode, LocaleNotLoadedError, } from './database'; import { importEmojiData } from './loader'; @@ -116,13 +117,20 @@ export async function loadEmojiDataToState( // First, try to load the data from IndexedDB. try { + const legacyCode = await loadLegacyShortcodesByShortcode(state.code); // This is duplicative, but that's because TS can't distinguish the state type easily. - if (state.type === EMOJI_TYPE_UNICODE) { - const data = await loadEmojiByHexcode(state.code, locale); + if (state.type === EMOJI_TYPE_UNICODE || legacyCode) { + const data = await loadEmojiByHexcode( + legacyCode?.hexcode ?? state.code, + locale, + ); if (data) { return { ...state, + type: EMOJI_TYPE_UNICODE, data, + // TODO: Use CLDR shortcodes when the picker supports them. + shortcode: legacyCode?.shortcodes.at(0), }; } } else { diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts index 8cd0902aa4..541fc428e6 100644 --- a/app/javascript/mastodon/features/emoji/types.ts +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -4,6 +4,7 @@ import type { FlatCompactEmoji, Locale } from 'emojibase'; import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; import type { CustomEmoji } from '@/mastodon/models/custom_emoji'; +import type { RequiredExcept } from '@/mastodon/utils/types'; import type { EMOJI_MODE_NATIVE, @@ -40,6 +41,7 @@ export interface EmojiStateUnicode { type: typeof EMOJI_TYPE_UNICODE; code: string; data?: UnicodeEmojiData; + shortcode?: string; } export interface EmojiStateCustom { type: typeof EMOJI_TYPE_CUSTOM; @@ -49,7 +51,7 @@ export interface EmojiStateCustom { export type EmojiState = EmojiStateUnicode | EmojiStateCustom; export type EmojiLoadedState = - | Required + | RequiredExcept | Required; export type CustomEmojiMapArg = diff --git a/app/javascript/mastodon/features/emoji/worker.ts b/app/javascript/mastodon/features/emoji/worker.ts index 5360484d77..2243678276 100644 --- a/app/javascript/mastodon/features/emoji/worker.ts +++ b/app/javascript/mastodon/features/emoji/worker.ts @@ -1,4 +1,9 @@ -import { importCustomEmojiData, importEmojiData } from './loader'; +import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants'; +import { + importCustomEmojiData, + importEmojiData, + importLegacyShortcodes, +} from './loader'; addEventListener('message', handleMessage); self.postMessage('ready'); // After the worker is ready, notify the main thread @@ -12,8 +17,10 @@ function handleMessage(event: MessageEvent<{ locale: string; path?: string }>) { async function loadData(locale: string, path?: string) { let importCount: number | undefined; - if (locale === 'custom') { + if (locale === EMOJI_TYPE_CUSTOM) { importCount = (await importCustomEmojiData())?.length; + } else if (locale === EMOJI_DB_NAME_SHORTCODES) { + importCount = (await importLegacyShortcodes()).length; } else if (path) { importCount = (await importEmojiData(locale, path))?.length; } else { diff --git a/app/javascript/mastodon/main.tsx b/app/javascript/mastodon/main.tsx index 249baf65fc..5d1d0aa513 100644 --- a/app/javascript/mastodon/main.tsx +++ b/app/javascript/mastodon/main.tsx @@ -55,9 +55,8 @@ function main() { 'Notification' in window && Notification.permission === 'granted' ) { - const registerPushNotifications = await import( - 'mastodon/actions/push_notifications' - ); + const registerPushNotifications = + await import('mastodon/actions/push_notifications'); store.dispatch(registerPushNotifications.register()); } diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts index 8fbc0cdf41..bd099b2334 100644 --- a/app/javascript/mastodon/models/account.ts +++ b/app/javascript/mastodon/models/account.ts @@ -42,10 +42,9 @@ const AccountRoleFactory = ImmutableRecord({ }); // Account -export interface AccountShape - extends Required< - Omit - > { +export interface AccountShape extends Required< + Omit +> { emojis: ImmutableList; fields: ImmutableList; roles: ImmutableList; diff --git a/app/javascript/mastodon/models/notification_group.ts b/app/javascript/mastodon/models/notification_group.ts index 9a74d01e8a..bb8799c0ec 100644 --- a/app/javascript/mastodon/models/notification_group.ts +++ b/app/javascript/mastodon/models/notification_group.ts @@ -14,20 +14,24 @@ import type { ApiReportJSON } from 'mastodon/api_types/reports'; // This corresponds to the max length of `group.sampleAccountIds` export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8; -interface BaseNotificationGroup - extends Omit { +interface BaseNotificationGroup extends Omit< + BaseNotificationGroupJSON, + 'sample_account_ids' +> { sampleAccountIds: string[]; partial: boolean; } -interface BaseNotificationWithStatus - extends BaseNotificationGroup { +interface BaseNotificationWithStatus< + Type extends NotificationWithStatusType, +> extends BaseNotificationGroup { type: Type; statusId: string | undefined; } -interface BaseNotification - extends BaseNotificationGroup { +interface BaseNotification< + Type extends NotificationType, +> extends BaseNotificationGroup { type: Type; } @@ -53,26 +57,25 @@ export type AccountWarningAction = | 'sensitive' | 'silence' | 'suspend'; -export interface AccountWarning - extends Omit { +export interface AccountWarning extends Omit< + ApiAccountWarningJSON, + 'target_account' +> { targetAccountId: string; } -export interface NotificationGroupModerationWarning - extends BaseNotification<'moderation_warning'> { +export interface NotificationGroupModerationWarning extends BaseNotification<'moderation_warning'> { moderationWarning: AccountWarning; } type AccountRelationshipSeveranceEvent = ApiAccountRelationshipSeveranceEventJSON; -export interface NotificationGroupSeveredRelationships - extends BaseNotification<'severed_relationships'> { +export interface NotificationGroupSeveredRelationships extends BaseNotification<'severed_relationships'> { event: AccountRelationshipSeveranceEvent; } type AnnualReportEvent = ApiAnnualReportEventJSON; -export interface NotificationGroupAnnualReport - extends BaseNotification<'annual_report'> { +export interface NotificationGroupAnnualReport extends BaseNotification<'annual_report'> { annualReport: AnnualReportEvent; } @@ -80,8 +83,7 @@ interface Report extends Omit { targetAccountId: string; } -export interface NotificationGroupAdminReport - extends BaseNotification<'admin.report'> { +export interface NotificationGroupAdminReport extends BaseNotification<'admin.report'> { report: Report; } diff --git a/app/javascript/mastodon/models/notification_request.ts b/app/javascript/mastodon/models/notification_request.ts index bd98d213d4..03e007c41e 100644 --- a/app/javascript/mastodon/models/notification_request.ts +++ b/app/javascript/mastodon/models/notification_request.ts @@ -1,7 +1,9 @@ import type { ApiNotificationRequestJSON } from 'mastodon/api_types/notifications'; -export interface NotificationRequest - extends Omit { +export interface NotificationRequest extends Omit< + ApiNotificationRequestJSON, + 'account' | 'notifications_count' +> { account_id: string; notifications_count: number; } diff --git a/app/javascript/mastodon/models/poll.ts b/app/javascript/mastodon/models/poll.ts index 46cbb1111d..154788700c 100644 --- a/app/javascript/mastodon/models/poll.ts +++ b/app/javascript/mastodon/models/poll.ts @@ -25,8 +25,10 @@ export function createPollOptionTranslationFromServerJSON(translation: { } as PollOptionTranslation; } -export interface Poll - extends Omit { +export interface Poll extends Omit< + ApiPollJSON, + 'emojis' | 'options' | 'own_votes' +> { emojis: CustomEmoji[]; options: PollOption[]; own_votes?: number[]; diff --git a/app/javascript/mastodon/reducers/slices/annual_report.ts b/app/javascript/mastodon/reducers/slices/annual_report.ts index e242fdbf9a..a687f558e1 100644 --- a/app/javascript/mastodon/reducers/slices/annual_report.ts +++ b/app/javascript/mastodon/reducers/slices/annual_report.ts @@ -62,7 +62,8 @@ export const checkAnnualReport = createAppThunk( `${annualReportSlice.name}/checkAnnualReport`, (_arg: unknown, { dispatch, getState }) => { const year = selectWrapstodonYear(getState()); - if (!year) { + const me = getState().meta.get('me') as string; + if (!year || !me) { return; } void dispatch(fetchReportState()); diff --git a/app/javascript/mastodon/utils/types.ts b/app/javascript/mastodon/utils/types.ts index eb45881ee4..019b074813 100644 --- a/app/javascript/mastodon/utils/types.ts +++ b/app/javascript/mastodon/utils/types.ts @@ -15,6 +15,8 @@ export type SomeRequired = T & Required>; export type SomeOptional = Pick> & Partial>; +export type RequiredExcept = SomeOptional, K>; + export type OmitValueType = { [K in keyof T as T[K] extends V ? never : K]: T[K]; }; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5344b1826a..236efc40c8 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -628,11 +628,6 @@ body > [data-popper-placement] { gap: 8px; margin: 8px; flex-wrap: wrap; - - & > div { - overflow: hidden; - display: flex; - } } &__uploads { @@ -772,7 +767,6 @@ body > [data-popper-placement] { align-items: center; flex: 1 1 auto; max-width: 100%; - overflow: hidden; } &__buttons { @@ -932,6 +926,11 @@ body > [data-popper-placement] { text-overflow: ellipsis; white-space: nowrap; + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: 2px; + } + &[disabled] { cursor: default; color: var(--color-text-disabled); diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index 95afb41028..72d7abe000 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -118,6 +118,7 @@ export function unicodeEmojiFactory( hexcode: 'test', label: 'Test', unicode: '๐Ÿงช', + shortcodes: ['test_emoji'], ...data, }; } diff --git a/app/lib/request.rb b/app/lib/request.rb index dd65b481d8..06c917c426 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -275,7 +275,7 @@ class Request end if ::HTTP::Response.methods.include?(:body_with_limit) && !Rails.env.production? - abort 'HTTP::Response#body_with_limit is already defined, the monkey patch will not be applied' + raise 'HTTP::Response#body_with_limit is already defined, the monkey patch will not be applied' else class ::HTTP::Response include Request::ClientLimit diff --git a/app/lib/status_filter.rb b/app/lib/status_filter.rb index 2313e1407d..6987fa872e 100644 --- a/app/lib/status_filter.rb +++ b/app/lib/status_filter.rb @@ -3,10 +3,9 @@ class StatusFilter attr_reader :status, :account - def initialize(status, account, preloaded_relations = {}) + def initialize(status, account) @status = status @account = account - @preloaded_relations = preloaded_relations end def filtered? @@ -40,15 +39,15 @@ class StatusFilter end def blocking_account? - @preloaded_relations[:blocking] ? @preloaded_relations[:blocking][status.account_id] : account.blocking?(status.account_id) + account.blocking?(status.account_id) end def blocking_domain? - @preloaded_relations[:domain_blocking_by_domain] ? @preloaded_relations[:domain_blocking_by_domain][status.account_domain] : account.domain_blocking?(status.account_domain) + account.domain_blocking?(status.account_domain) end def muting_account? - @preloaded_relations[:muting] ? @preloaded_relations[:muting][status.account_id] : account.muting?(status.account_id) + account.muting?(status.account_id) end def silenced_account? @@ -60,7 +59,7 @@ class StatusFilter end def account_following_status_account? - @preloaded_relations[:following] ? @preloaded_relations[:following][status.account_id] : account&.following?(status.account_id) + account&.following?(status.account_id) end def blocked_by_policy? @@ -68,6 +67,6 @@ class StatusFilter end def policy_allows_show? - StatusPolicy.new(account, status, @preloaded_relations).show? + StatusPolicy.new(account, status).show? end end diff --git a/app/models/concerns/account/interactions.rb b/app/models/concerns/account/interactions.rb index 7f1d91a160..c51ccf1229 100644 --- a/app/models/concerns/account/interactions.rb +++ b/app/models/concerns/account/interactions.rb @@ -123,7 +123,11 @@ module Account::Interactions end def following?(other_account) - active_relationships.exists?(target_account: other_account) + other_id = other_account.is_a?(Account) ? other_account.id : other_account + + preloaded_relation(:following, other_id) do + active_relationships.exists?(target_account: other_account) + end end def following_anyone? @@ -139,15 +143,33 @@ module Account::Interactions end def blocking?(other_account) - block_relationships.exists?(target_account: other_account) + other_id = other_account.is_a?(Account) ? other_account.id : other_account + + preloaded_relation(:blocking, other_id) do + block_relationships.exists?(target_account: other_account) + end + end + + def blocked_by?(other_account) + other_id = other_account.is_a?(Account) ? other_account.id : other_account + + preloaded_relation(:blocked_by, other_id) do + other_account.block_relationships.exists?(target_account: self) + end end def domain_blocking?(other_domain) - domain_blocks.exists?(domain: other_domain) + preloaded_relation(:domain_blocking_by_domain, other_domain) do + domain_blocks.exists?(domain: other_domain) + end end def muting?(other_account) - mute_relationships.exists?(target_account: other_account) + other_id = other_account.is_a?(Account) ? other_account.id : other_account + + preloaded_relation(:muting, other_id) do + mute_relationships.exists?(target_account: other_account) + end end def muting_conversation?(conversation) @@ -226,4 +248,10 @@ module Account::Interactions def normalized_domain(domain) TagManager.instance.normalize_domain(domain) end + + private + + def preloaded_relation(type, key) + @preloaded_relations && @preloaded_relations[type] ? @preloaded_relations[type][key].present? : yield + end end diff --git a/app/models/concerns/account/mappings.rb b/app/models/concerns/account/mappings.rb index c4eddc1fc2..b8b43cad7c 100644 --- a/app/models/concerns/account/mappings.rb +++ b/app/models/concerns/account/mappings.rb @@ -91,6 +91,12 @@ module Account::Mappings end end + def preload_relations!(...) + @preloaded_relations = relations_map(...) + end + + private + def relations_map(account_ids, domains = nil, **options) relations = { blocked_by: Account.blocked_by_map(account_ids, id), diff --git a/app/models/concerns/status/interaction_policy_concern.rb b/app/models/concerns/status/interaction_policy_concern.rb index da132a450e..ed1e7a237f 100644 --- a/app/models/concerns/status/interaction_policy_concern.rb +++ b/app/models/concerns/status/interaction_policy_concern.rb @@ -26,7 +26,7 @@ module Status::InteractionPolicyConcern end # Returns `:automatic`, `:manual`, `:unknown` or `:denied` - def quote_policy_for_account(other_account, preloaded_relations: {}) + def quote_policy_for_account(other_account) return :denied if other_account.nil? || direct_visibility? || reblog? following_author = nil @@ -41,7 +41,7 @@ module Status::InteractionPolicyConcern return :automatic if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public]) if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers]) - following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil? + following_author = other_account.following?(account) if following_author.nil? return :automatic if following_author end @@ -54,7 +54,7 @@ module Status::InteractionPolicyConcern return :manual if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public]) if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers]) - following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil? + following_author = other_account.following?(account) if following_author.nil? return :manual if following_author end diff --git a/app/models/concerns/status/threading_concern.rb b/app/models/concerns/status/threading_concern.rb index 478a139d63..3b0a3cd028 100644 --- a/app/models/concerns/status/threading_concern.rb +++ b/app/models/concerns/status/threading_concern.rb @@ -8,9 +8,10 @@ module Status::ThreadingConcern statuses = Status.with_accounts(ids).to_a account_ids = statuses.map(&:account_id).uniq domains = statuses.filter_map(&:account_domain).uniq - relations = account&.relations_map(account_ids, domains) || {} - statuses.reject! { |status| StatusFilter.new(status, account, relations).filtered? } + account&.preload_relations!(account_ids, domains) + + statuses.reject! { |status| StatusFilter.new(status, account).filtered? } if stable statuses.sort_by! { |status| ids.index(status.id) } diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 50fa9b4d5c..ab3b41d628 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -66,6 +66,6 @@ class AccountPolicy < ApplicationPolicy end def feature? - record.featureable? && !current_account.blocking?(record) && !record.blocking?(current_account) + record.featureable? && !current_account.blocking?(record) && !current_account.blocked_by?(record) end end diff --git a/app/policies/admin/status_policy.rb b/app/policies/admin/status_policy.rb index c4ba5c2606..b0b42ce5d8 100644 --- a/app/policies/admin/status_policy.rb +++ b/app/policies/admin/status_policy.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true class Admin::StatusPolicy < ApplicationPolicy - def initialize(current_account, record, preloaded_relations = {}) - super(current_account, record) - - @preloaded_relations = preloaded_relations - end - def index? role.can?(:manage_reports, :manage_users) end @@ -34,6 +28,6 @@ class Admin::StatusPolicy < ApplicationPolicy end def viewable_through_normal_policy? - StatusPolicy.new(current_account, record, @preloaded_relations).show? + StatusPolicy.new(current_account, record).show? end end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index f598709ff5..e5353363f8 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true class StatusPolicy < ApplicationPolicy - def initialize(current_account, record, preloaded_relations = {}) - super(current_account, record) - - @preloaded_relations = preloaded_relations - end - def show? return false if author.unavailable? return false if local_only? && (current_account.nil? || !current_account.local?) @@ -22,7 +16,7 @@ class StatusPolicy < ApplicationPolicy # This is about requesting a quote post, not validating it def quote? - show? && record.quote_policy_for_account(current_account, preloaded_relations: @preloaded_relations) != :denied + show? && record.quote_policy_for_account(current_account) != :denied end def reblog? @@ -76,19 +70,19 @@ class StatusPolicy < ApplicationPolicy def blocking_author? return false if current_account.nil? - @preloaded_relations[:blocking] ? @preloaded_relations[:blocking][author.id] : current_account.blocking?(author) + current_account.blocking?(author) end def author_blocking? return false if current_account.nil? - @preloaded_relations[:blocked_by] ? @preloaded_relations[:blocked_by][author.id] : author.blocking?(current_account) + current_account.blocked_by?(author) end def following_author? return false if current_account.nil? - @preloaded_relations[:following] ? @preloaded_relations[:following][author.id] : current_account.following?(author) + current_account.following?(author) end def author diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 0807f1c95e..060a0a8ed6 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -41,14 +41,8 @@ class StatusRelationshipsPresenter end # This one is currently on-demand as it is only used for quote posts - def preloaded_account_relations - @preloaded_account_relations ||= begin - accounts = @statuses.compact.flat_map { |s| [s.account, s.proper.account, s.proper.quote&.quoted_account] }.uniq.compact - - account_ids = accounts.pluck(:id) - account_domains = accounts.pluck(:domain).uniq - Account.find(@current_account_id).relations_map(account_ids, account_domains) - end + def authoring_accounts + @authoring_accounts ||= @statuses.compact.flat_map { |s| [s.account, s.proper.account, s.proper.quote&.quoted_account] }.uniq.compact end private diff --git a/app/serializers/rest/base_quote_serializer.rb b/app/serializers/rest/base_quote_serializer.rb index ac3b545d5c..407bc961f6 100644 --- a/app/serializers/rest/base_quote_serializer.rb +++ b/app/serializers/rest/base_quote_serializer.rb @@ -19,6 +19,13 @@ class REST::BaseQuoteSerializer < ActiveModel::Serializer private def status_filter - @status_filter ||= StatusFilter.new(object.quoted_status, current_user&.account, instance_options[:relationships]&.preloaded_account_relations || {}) + @status_filter ||= begin + if current_user && instance_options[:relationships] + account_ids = instance_options[:relationships].authoring_accounts.pluck(:id) + domains = instance_options[:relationships].authoring_accounts.pluck(:domain).uniq + current_user.account.preload_relations!(account_ids, domains) + end + StatusFilter.new(object.quoted_status, current_user&.account) + end end end diff --git a/app/services/create_collection_service.rb b/app/services/create_collection_service.rb index 92c26879d1..10843cb967 100644 --- a/app/services/create_collection_service.rb +++ b/app/services/create_collection_service.rb @@ -2,9 +2,10 @@ class CreateCollectionService def call(params, account) - account_ids = params.delete(:account_ids) + @account = account + @accounts_to_add = Account.find(params.delete(:account_ids) || []) @collection = Collection.new(params.merge({ account:, local: true })) - build_items(account_ids) + build_items @collection.save! @collection @@ -12,13 +13,14 @@ class CreateCollectionService private - def build_items(account_ids) - return if account_ids.blank? + def build_items + return if @accounts_to_add.empty? - account_ids.each do |account_id| - account = Account.find(account_id) - # TODO: validate preferences - @collection.collection_items.build(account:) + @account.preload_relations!(@accounts_to_add.map(&:id)) + @accounts_to_add.each do |account_to_add| + raise Mastodon::NotPermittedError, I18n.t('accounts.errors.cannot_be_added_to_collections') unless AccountPolicy.new(@account, account_to_add).feature? + + @collection.collection_items.build(account: account_to_add) end end end diff --git a/app/services/statuses_search_service.rb b/app/services/statuses_search_service.rb index 6dec465464..3147349d70 100644 --- a/app/services/statuses_search_service.rb +++ b/app/services/statuses_search_service.rb @@ -29,9 +29,10 @@ class StatusesSearchService < BaseService results = request.collapse(field: :id).order(id: { order: :desc }).limit(@limit).offset(@offset).objects.compact account_ids = results.map(&:account_id) account_domains = results.map(&:account_domain) - preloaded_relations = @account.relations_map(account_ids, account_domains) - results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? } + @account.preload_relations!(account_ids, account_domains) + + results.reject { |status| StatusFilter.new(status, @account).filtered? } rescue Faraday::ConnectionFailed, Parslet::ParseFailed, Errno::ENETUNREACH [] end diff --git a/app/views/wrapstodon/show.html.haml b/app/views/wrapstodon/show.html.haml index d35137ebf7..7d1c15bf94 100644 --- a/app/views/wrapstodon/show.html.haml +++ b/app/views/wrapstodon/show.html.haml @@ -11,7 +11,6 @@ = render 'og_description', account: @account = render 'og_image', report: @generated_annual_report - = render_initial_state = flavoured_vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous' - content_for :html_classes, 'theme-dark' diff --git a/config/boot.rb b/config/boot.rb index 70ffe22c04..29e8e9e349 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true unless ENV.key?('RAILS_ENV') - abort <<~ERROR + abort <<~ERROR # rubocop:disable Rails/Exit The RAILS_ENV environment variable is not set. Please set it correctly depending on context: diff --git a/config/initializers/active_record_encryption.rb b/config/initializers/active_record_encryption.rb index 9ae28e401b..3f9125674c 100644 --- a/config/initializers/active_record_encryption.rb +++ b/config/initializers/active_record_encryption.rb @@ -13,7 +13,7 @@ value = ENV.fetch(key, '') if value.blank? - abort <<~MESSAGE + abort <<~MESSAGE # rubocop:disable Rails/Exit Mastodon now requires that these variables are set: @@ -28,7 +28,7 @@ next unless Rails.env.production? && value.end_with?('DO_NOT_USE_IN_PRODUCTION') - abort <<~MESSAGE + abort <<~MESSAGE # rubocop:disable Rails/Exit It looks like you are trying to run Mastodon in production with a #{key} value from the test environment. diff --git a/config/initializers/deprecations.rb b/config/initializers/deprecations.rb index 15c701c9c1..520707e59f 100644 --- a/config/initializers/deprecations.rb +++ b/config/initializers/deprecations.rb @@ -14,7 +14,7 @@ if ENV['REDIS_NAMESPACE'] In addition, as REDIS_NAMESPACE is being used as a prefix for Elasticsearch, please do not forget to set ES_PREFIX to "#{ENV.fetch('REDIS_NAMESPACE')}". MESSAGE - abort message + abort message # rubocop:disable Rails/Exit end if ENV['MASTODON_USE_LIBVIPS'] == 'false' diff --git a/config/initializers/vips.rb b/config/initializers/vips.rb index a539d7035c..09210d60eb 100644 --- a/config/initializers/vips.rb +++ b/config/initializers/vips.rb @@ -6,7 +6,7 @@ if Rails.configuration.x.use_vips require 'vips' unless Vips.at_least_libvips?(8, 13) - abort <<~ERROR.squish + abort <<~ERROR.squish # rubocop:disable Rails/Exit Incompatible libvips version (#{Vips.version_string}), please install libvips >= 8.13 ERROR end diff --git a/config/routes/api.rb b/config/routes/api.rb index f0557a5d39..b600f1631d 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -13,7 +13,7 @@ namespace :api, format: false do resources :async_refreshes, only: :show resources :collections, only: [:show, :create, :update, :destroy] do - resources :items, only: [:create], controller: 'collection_items' + resources :items, only: [:create, :destroy], controller: 'collection_items' end end diff --git a/config/vite/plugin-emoji-compressed.ts b/config/vite/plugin-emoji-compressed.ts index e8a2174c3c..dd81d19f55 100644 --- a/config/vite/plugin-emoji-compressed.ts +++ b/config/vite/plugin-emoji-compressed.ts @@ -15,9 +15,8 @@ export function MastodonEmojiCompressed(): Plugin { }, async load(id) { if (id === resolvedVirtualModuleId) { - const { default: emojiCompressed } = await import( - '../../app/javascript/mastodon/features/emoji/emoji_compressed.mjs' - ); + const { default: emojiCompressed } = + await import('../../app/javascript/mastodon/features/emoji/emoji_compressed.mjs'); return `export default ${JSON.stringify(emojiCompressed)};`; } diff --git a/package.json b/package.json index 1b5019c7b2..cc698edeca 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "stacktrace-js": "^2.0.2", "stringz": "^2.1.0", "substring-trie": "^1.0.2", - "tesseract.js": "^6.0.0", + "tesseract.js": "^7.0.0", "tiny-queue": "^0.2.1", "twitter-text": "3.1.0", "use-debounce": "^10.0.0", diff --git a/spec/models/concerns/account/interactions_spec.rb b/spec/models/concerns/account/interactions_spec.rb index b683259c8c..cc50c46551 100644 --- a/spec/models/concerns/account/interactions_spec.rb +++ b/spec/models/concerns/account/interactions_spec.rb @@ -302,9 +302,24 @@ RSpec.describe Account::Interactions do subject { account.following?(target_account) } context 'when following target_account' do - it 'returns true' do + before do account.active_relationships.create(target_account: target_account) - expect(subject).to be true + end + + it 'returns true' do + result = nil + expect { result = subject }.to execute_queries + expect(result).to be true + end + + context 'when relations are preloaded' do + it 'does not query the database to get the result' do + account.preload_relations!([target_account.id]) + + result = nil + expect { result = subject }.to_not execute_queries + expect(result).to be true + end end end @@ -336,9 +351,26 @@ RSpec.describe Account::Interactions do subject { account.blocking?(target_account) } context 'when blocking target_account' do - it 'returns true' do + before do account.block_relationships.create(target_account: target_account) - expect(subject).to be true + end + + it 'returns true' do + result = nil + expect { result = subject }.to execute_queries + + expect(result).to be true + end + + context 'when relations are preloaded' do + it 'does not query the database to get the result' do + account.preload_relations!([target_account.id]) + + result = nil + expect { result = subject }.to_not execute_queries + + expect(result).to be true + end end end @@ -349,16 +381,65 @@ RSpec.describe Account::Interactions do end end + describe '#blocked_by?' do + subject { account.blocked_by?(target_account) } + + context 'when blocked by target_account' do + before do + target_account.block_relationships.create(target_account: account) + end + + it 'returns true' do + result = nil + expect { result = subject }.to execute_queries + + expect(result).to be true + end + + context 'when relations are preloaded' do + it 'does not query the database to get the result' do + account.preload_relations!([target_account.id]) + + result = nil + expect { result = subject }.to_not execute_queries + + expect(result).to be true + end + end + end + + context 'when not blocked by target_account' do + it 'returns false' do + expect(subject).to be false + end + end + end + describe '#domain_blocking?' do subject { account.domain_blocking?(domain) } let(:domain) { 'example.com' } context 'when blocking the domain' do - it 'returns true' do + before do account_domain_block = Fabricate(:account_domain_block, domain: domain) account.domain_blocks << account_domain_block - expect(subject).to be true + end + + it 'returns true' do + result = nil + expect { result = subject }.to execute_queries + expect(result).to be true + end + + context 'when relations are preloaded' do + it 'does not query the database to get the result' do + account.preload_relations!([], [domain]) + + result = nil + expect { result = subject }.to_not execute_queries + expect(result).to be true + end end end @@ -373,10 +454,25 @@ RSpec.describe Account::Interactions do subject { account.muting?(target_account) } context 'when muting target_account' do - it 'returns true' do + before do mute = Fabricate(:mute, account: account, target_account: target_account) account.mute_relationships << mute - expect(subject).to be true + end + + it 'returns true' do + result = nil + expect { result = subject }.to execute_queries + expect(result).to be true + end + + context 'when relations are preloaded' do + it 'does not query the database to get the result' do + account.preload_relations!([target_account.id]) + + result = nil + expect { result = subject }.to_not execute_queries + expect(result).to be true + end end end diff --git a/spec/requests/api/v1_alpha/collection_items_spec.rb b/spec/requests/api/v1_alpha/collection_items_spec.rb index 880fd5d47d..5c44a7edf8 100644 --- a/spec/requests/api/v1_alpha/collection_items_spec.rb +++ b/spec/requests/api/v1_alpha/collection_items_spec.rb @@ -52,4 +52,53 @@ RSpec.describe 'Api::V1Alpha::CollectionItems', feature: :collections do end end end + + describe 'DELETE /api/v1_alpha/collections/:collection_id/items/:id' do + subject do + delete "/api/v1_alpha/collections/#{collection.id}/items/#{item.id}", headers: headers + end + + let(:collection) { Fabricate(:collection, account: user.account) } + let(:item) { Fabricate(:collection_item, collection:) } + + it_behaves_like 'forbidden for wrong scope', 'read' + + context 'when user is owner of the collection' do + context 'when item belongs to collection' do + it 'deletes the collection item and returns http success' do + item # Make sure this exists before calling the API + + expect do + subject + end.to change(collection.collection_items, :count).by(-1) + + expect(response).to have_http_status(200) + end + end + + context 'when item does not belong to to collection' do + let(:item) { Fabricate(:collection_item) } + + it 'returns http not found' do + item # Make sure this exists before calling the API + + expect do + subject + end.to_not change(CollectionItem, :count) + + expect(response).to have_http_status(404) + end + end + end + + context 'when user is not the owner of the collection' do + let(:collection) { Fabricate(:collection) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + end end diff --git a/spec/services/create_collection_service_spec.rb b/spec/services/create_collection_service_spec.rb index bf59e299b1..f88a366a6c 100644 --- a/spec/services/create_collection_service_spec.rb +++ b/spec/services/create_collection_service_spec.rb @@ -30,9 +30,10 @@ RSpec.describe CreateCollectionService do end context 'when given account ids' do - let(:account_ids) do - Fabricate.times(2, :account).map { |a| a.id.to_s } + let(:accounts) do + Fabricate.times(2, :account) end + let(:account_ids) { accounts.map { |a| a.id.to_s } } let(:params) do base_params.merge(account_ids:) end @@ -42,6 +43,18 @@ RSpec.describe CreateCollectionService do subject.call(params, author) end.to change(CollectionItem, :count).by(2) end + + context 'when one account may not be added' do + before do + accounts.last.update(discoverable: false) + end + + it 'raises an error' do + expect do + subject.call(params, author) + end.to raise_error(Mastodon::NotPermittedError) + end + end end context 'when given a tag' do diff --git a/spec/support/matchers/sql_queries.rb b/spec/support/matchers/sql_queries.rb new file mode 100644 index 0000000000..c37eefb06d --- /dev/null +++ b/spec/support/matchers/sql_queries.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'active_record/testing/query_assertions' + +# Implement something similar to Rails' built-in assertion. +# Can be removed once https://github.com/rspec/rspec-rails/pull/2818 +# has been merged and released. +RSpec::Matchers.define :execute_queries do |expected = nil| + match do |actual| + counter = ActiveRecord::Assertions::QueryAssertions::SQLCounter.new + + queries = ActiveSupport::Notifications.subscribed(counter, 'sql.active_record') do + actual.call + counter.log + end + + if expected.nil? + queries.count >= 1 + else + queries.count == expected + end + end + + supports_block_expectations +end diff --git a/yarn.lock b/yarn.lock index 9091769060..ca0846ee9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1306,6 +1306,13 @@ __metadata: languageName: node linkType: hard +"@csstools/css-syntax-patches-for-csstree@npm:^1.0.19": + version: 1.0.20 + resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.0.20" + checksum: 10c0/335fcd24eb563068338153066d580bfdfc87b1e0f7650432a332e925c88d247a56f8e5851cd27dd68e49cde2dbeb465db60a51bb92a18e6721b5166b2e046d91 + languageName: node + linkType: hard + "@csstools/css-tokenizer@npm:^3.0.4": version: 3.0.4 resolution: "@csstools/css-tokenizer@npm:3.0.4" @@ -2868,7 +2875,7 @@ __metadata: stylelint-config-prettier-scss: "npm:^1.0.0" stylelint-config-standard-scss: "npm:^16.0.0" substring-trie: "npm:^1.0.2" - tesseract.js: "npm:^6.0.0" + tesseract.js: "npm:^7.0.0" tiny-queue: "npm:^0.2.1" twitter-text: "npm:3.1.0" typescript: "npm:~5.9.0" @@ -4846,28 +4853,28 @@ __metadata: linkType: hard "@vitest/browser-playwright@npm:^4.0.5": - version: 4.0.13 - resolution: "@vitest/browser-playwright@npm:4.0.13" + version: 4.0.15 + resolution: "@vitest/browser-playwright@npm:4.0.15" dependencies: - "@vitest/browser": "npm:4.0.13" - "@vitest/mocker": "npm:4.0.13" + "@vitest/browser": "npm:4.0.15" + "@vitest/mocker": "npm:4.0.15" tinyrainbow: "npm:^3.0.3" peerDependencies: playwright: "*" - vitest: 4.0.13 + vitest: 4.0.15 peerDependenciesMeta: playwright: optional: false - checksum: 10c0/5a387eb02534736a25cfff442e66e8c41ef97f0db744ffe8360e484af61d66db793cb44ba8681471b8c21ba509db1775f1ba688bc7f50325eee76918773848cb + checksum: 10c0/ce357cc96b5d391fa701d545089475feac64e6febdce0d95a75e7c9c29ad35650372e6930a492750af2a4633f4f9354463968f435713da4f035befeb4e3ecf84 languageName: node linkType: hard -"@vitest/browser@npm:4.0.13, @vitest/browser@npm:^4.0.5": - version: 4.0.13 - resolution: "@vitest/browser@npm:4.0.13" +"@vitest/browser@npm:4.0.15, @vitest/browser@npm:^4.0.5": + version: 4.0.15 + resolution: "@vitest/browser@npm:4.0.15" dependencies: - "@vitest/mocker": "npm:4.0.13" - "@vitest/utils": "npm:4.0.13" + "@vitest/mocker": "npm:4.0.15" + "@vitest/utils": "npm:4.0.15" magic-string: "npm:^0.30.21" pixelmatch: "npm:7.1.0" pngjs: "npm:^7.0.0" @@ -4875,33 +4882,33 @@ __metadata: tinyrainbow: "npm:^3.0.3" ws: "npm:^8.18.3" peerDependencies: - vitest: 4.0.13 - checksum: 10c0/22c9297888a7288717cad706ca08159b3af05337a2f9b8da98fe74e683d534c8d816e40fece96f218d223a54c06762c5aa2a5db23ce8565c174ab9a70aade7f0 + vitest: 4.0.15 + checksum: 10c0/b74c1ab5b03a494b1a91e270417a794e616d3d9d5002de816b6a9913073fdf5939ca63b30a37e4e865cb9402b8682254facaf4b854d002b65b6ea85fccf38253 languageName: node linkType: hard "@vitest/coverage-v8@npm:^4.0.5": - version: 4.0.13 - resolution: "@vitest/coverage-v8@npm:4.0.13" + version: 4.0.15 + resolution: "@vitest/coverage-v8@npm:4.0.15" dependencies: "@bcoe/v8-coverage": "npm:^1.0.2" - "@vitest/utils": "npm:4.0.13" + "@vitest/utils": "npm:4.0.15" ast-v8-to-istanbul: "npm:^0.3.8" - debug: "npm:^4.4.3" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-report: "npm:^3.0.1" istanbul-lib-source-maps: "npm:^5.0.6" istanbul-reports: "npm:^3.2.0" magicast: "npm:^0.5.1" + obug: "npm:^2.1.1" std-env: "npm:^3.10.0" tinyrainbow: "npm:^3.0.3" peerDependencies: - "@vitest/browser": 4.0.13 - vitest: 4.0.13 + "@vitest/browser": 4.0.15 + vitest: 4.0.15 peerDependenciesMeta: "@vitest/browser": optional: true - checksum: 10c0/dd462b13605fca62d20cb5a4f9d7cfda2bfa5e77aedc16ad5a633d8dabb24f68e96382ac4d16c2fdcddb45e7c4717e558f5ac51a70c64857f5e89d12d8700823 + checksum: 10c0/8810cb35fc443bdd5da46ea90804d7657d17ceb20dc9f7e05c7f6212480039c6079ec4ff0c305a658044b7cbd8792a71c7ae6661258fc5f3022e04bea04186a0 languageName: node linkType: hard @@ -4918,17 +4925,17 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:4.0.13": - version: 4.0.13 - resolution: "@vitest/expect@npm:4.0.13" +"@vitest/expect@npm:4.0.15": + version: 4.0.15 + resolution: "@vitest/expect@npm:4.0.15" dependencies: "@standard-schema/spec": "npm:^1.0.0" "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.0.13" - "@vitest/utils": "npm:4.0.13" + "@vitest/spy": "npm:4.0.15" + "@vitest/utils": "npm:4.0.15" chai: "npm:^6.2.1" tinyrainbow: "npm:^3.0.3" - checksum: 10c0/1cd7dc02cb650d024826f2e20260d23c2b9ab6733341045ffb59be7af73402eecd2422198d7e4eac609710730b6d11f0faf22af0c074d65445ab88d9da7f6556 + checksum: 10c0/0cb98a4918ca84b28cd14120bb66c1bc3084f8f95b649066cdab2f5234ecdbe247cdc6bc47c0d939521d964ff3c150aadd9558272495c26872c9f3a97373bf7b languageName: node linkType: hard @@ -4951,11 +4958,11 @@ __metadata: languageName: node linkType: hard -"@vitest/mocker@npm:4.0.13": - version: 4.0.13 - resolution: "@vitest/mocker@npm:4.0.13" +"@vitest/mocker@npm:4.0.15": + version: 4.0.15 + resolution: "@vitest/mocker@npm:4.0.15" dependencies: - "@vitest/spy": "npm:4.0.13" + "@vitest/spy": "npm:4.0.15" estree-walker: "npm:^3.0.3" magic-string: "npm:^0.30.21" peerDependencies: @@ -4966,7 +4973,7 @@ __metadata: optional: true vite: optional: true - checksum: 10c0/667ec4fbb77a28ede1b055b9d962beed92c2dd2d83b7bab1ed22239578a7b399180a978e26ef136301c0bc7c57c75ca178cda55ec94081856437e3b4be4a3e19 + checksum: 10c0/7a164aa25daab3e92013ec95aab5c5778e805b1513e266ce6c00e0647eb9f7b281e33fcaf0d9d2aed88321079183b60c1aeab90961f618c24e2e3a5143308850 languageName: node linkType: hard @@ -4979,33 +4986,33 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:4.0.13": - version: 4.0.13 - resolution: "@vitest/pretty-format@npm:4.0.13" +"@vitest/pretty-format@npm:4.0.15": + version: 4.0.15 + resolution: "@vitest/pretty-format@npm:4.0.15" dependencies: tinyrainbow: "npm:^3.0.3" - checksum: 10c0/c32ebd3457fd4b92fa89800b0ddaa2ca7de84df75be3c64f87ace006f3a3ec546a6ffd4c06f88e3161e80f9e10c83dfee61150e682eaa5a1871240d98c7ef0eb + checksum: 10c0/d863f3818627b198f9c66515f8faa200e76a1c30c7f2b25ac805e253204ae51abbfa6b6211c58b2d75e1a273a2e6925e3a8fa480ebfa9c479d75a19815e1cbea languageName: node linkType: hard -"@vitest/runner@npm:4.0.13": - version: 4.0.13 - resolution: "@vitest/runner@npm:4.0.13" +"@vitest/runner@npm:4.0.15": + version: 4.0.15 + resolution: "@vitest/runner@npm:4.0.15" dependencies: - "@vitest/utils": "npm:4.0.13" + "@vitest/utils": "npm:4.0.15" pathe: "npm:^2.0.3" - checksum: 10c0/e9f95b8a413f875123e5c32322dd92bd523d6e3ba25b054f0e865f42e01f82666b847535fe5ea2ff3238faa2df16cefc7e5845d3d5ccfecb3a96ab924d31e760 + checksum: 10c0/0b0f23b8fed1a98bb85d71a7fc105726e0fae68667b090c40b636011126fef548a5f853eab40aaf47314913ab6480eefe2aa5bd6bcefc4116e034fdc1ac0f7d0 languageName: node linkType: hard -"@vitest/snapshot@npm:4.0.13": - version: 4.0.13 - resolution: "@vitest/snapshot@npm:4.0.13" +"@vitest/snapshot@npm:4.0.15": + version: 4.0.15 + resolution: "@vitest/snapshot@npm:4.0.15" dependencies: - "@vitest/pretty-format": "npm:4.0.13" + "@vitest/pretty-format": "npm:4.0.15" magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10c0/ad3fbe9ff30bc294811556f958e0014cb03888ea06ac7c05ab41e20c582fe8e27d8f4176aaf8a8e230fc6377124af30f5622173fb459b70a30ff9dd622664be2 + checksum: 10c0/f05a19f74512cbad9bcfe4afe814c676b72b7e54ccf09c5b36e06e73614a24fdba47bdb8a94279162b7fdf83c9c840f557073a114a9339df7e75ccb9f4e99218 languageName: node linkType: hard @@ -5018,18 +5025,18 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:4.0.13": - version: 4.0.13 - resolution: "@vitest/spy@npm:4.0.13" - checksum: 10c0/64dc4c496eb9aacd3137beedccdb3265c895f8cd2626b3f76d7324ad944be5b1567ede2652eee407991796879270a63abdec4453c73185e637a1d7ff9cd1a009 +"@vitest/spy@npm:4.0.15": + version: 4.0.15 + resolution: "@vitest/spy@npm:4.0.15" + checksum: 10c0/22319cead44964882d9e7bd197a9cd317c945ff75a4a9baefe06c32c5719d4cb887e86b4560d79716765f288a93a6c76e78e3eeab0000f24b2236dab678b7c34 languageName: node linkType: hard "@vitest/ui@npm:^4.0.5": - version: 4.0.13 - resolution: "@vitest/ui@npm:4.0.13" + version: 4.0.15 + resolution: "@vitest/ui@npm:4.0.15" dependencies: - "@vitest/utils": "npm:4.0.13" + "@vitest/utils": "npm:4.0.15" fflate: "npm:^0.8.2" flatted: "npm:^3.3.3" pathe: "npm:^2.0.3" @@ -5037,8 +5044,8 @@ __metadata: tinyglobby: "npm:^0.2.15" tinyrainbow: "npm:^3.0.3" peerDependencies: - vitest: 4.0.13 - checksum: 10c0/7656762bc6a9c99850639d0809ada53ad4b842e4d9a8c7b82987b60bcf1675c98c077516a3777fce9580255538d0d050c92cb1e6f6296af6365f2387d7a972b9 + vitest: 4.0.15 + checksum: 10c0/f6d1729de4d0ab43fbcc48aa1935315ab1c039fcf216abc01222e9170e0cb1829300a950952ca025ee9af0618fb4c24199cb1a912a76466c87a0eee86ed91d11 languageName: node linkType: hard @@ -5053,13 +5060,13 @@ __metadata: languageName: node linkType: hard -"@vitest/utils@npm:4.0.13": - version: 4.0.13 - resolution: "@vitest/utils@npm:4.0.13" +"@vitest/utils@npm:4.0.15": + version: 4.0.15 + resolution: "@vitest/utils@npm:4.0.15" dependencies: - "@vitest/pretty-format": "npm:4.0.13" + "@vitest/pretty-format": "npm:4.0.15" tinyrainbow: "npm:^3.0.3" - checksum: 10c0/1b64872e82a652f11bfd813c0140eaae9b6e4ece39fc0e460ab2b3111b925892f1128f3b27f3a280471cfc404bb9c9289c59f8ca5387950ab35d024d154e9ec1 + checksum: 10c0/2ef661c2c2359ae956087f0b728b6a0f7555cd10524a7def27909f320f6b8ba00560ee1bd856bf68d4debc01020cea21b200203a5d2af36c44e94528c5587aee languageName: node linkType: hard @@ -7432,10 +7439,10 @@ __metadata: languageName: node linkType: hard -"fast-copy@npm:^3.0.2": - version: 3.0.2 - resolution: "fast-copy@npm:3.0.2" - checksum: 10c0/02e8b9fd03c8c024d2987760ce126456a0e17470850b51e11a1c3254eed6832e4733ded2d93316c82bc0b36aeb991ad1ff48d1ba95effe7add7c3ab8d8eb554a +"fast-copy@npm:^4.0.0": + version: 4.0.1 + resolution: "fast-copy@npm:4.0.1" + checksum: 10c0/5c0ccddc68cb8f071d7806681f4bb87741cc07ed3153bba49adc456607c6d38854bf921c318feba402442056f7babcdc435052f431f19b2c7c6cbeefe9ae5841 languageName: node linkType: hard @@ -7529,7 +7536,7 @@ __metadata: languageName: node linkType: hard -"file-entry-cache@npm:^11.1.0": +"file-entry-cache@npm:^11.1.1": version: 11.1.1 resolution: "file-entry-cache@npm:11.1.1" dependencies: @@ -10050,6 +10057,13 @@ __metadata: languageName: node linkType: hard +"obug@npm:^2.1.1": + version: 2.1.1 + resolution: "obug@npm:2.1.1" + checksum: 10c0/59dccd7de72a047e08f8649e94c1015ec72f94eefb6ddb57fb4812c4b425a813bc7e7cd30c9aca20db3c59abc3c85cc7a62bb656a968741d770f4e8e02bc2e78 + languageName: node + linkType: hard + "on-exit-leak-free@npm:^2.1.0": version: 2.1.2 resolution: "on-exit-leak-free@npm:2.1.2" @@ -10461,6 +10475,15 @@ __metadata: languageName: node linkType: hard +"pino-abstract-transport@npm:^3.0.0": + version: 3.0.0 + resolution: "pino-abstract-transport@npm:3.0.0" + dependencies: + split2: "npm:^4.0.0" + checksum: 10c0/4486e1b9508110aaf963d07741ac98d660b974dd51d8ad42077d215118e27cda20c64da46c07c926898d52540aab7c6b9c37dc0f5355c203bb1d6a72b5bd8d6c + languageName: node + linkType: hard + "pino-http@npm:^11.0.0": version: 11.0.0 resolution: "pino-http@npm:11.0.0" @@ -10474,25 +10497,25 @@ __metadata: linkType: hard "pino-pretty@npm:^13.0.0": - version: 13.1.2 - resolution: "pino-pretty@npm:13.1.2" + version: 13.1.3 + resolution: "pino-pretty@npm:13.1.3" dependencies: colorette: "npm:^2.0.7" dateformat: "npm:^4.6.3" - fast-copy: "npm:^3.0.2" + fast-copy: "npm:^4.0.0" fast-safe-stringify: "npm:^2.1.1" help-me: "npm:^5.0.0" joycon: "npm:^3.1.1" minimist: "npm:^1.2.6" on-exit-leak-free: "npm:^2.1.0" - pino-abstract-transport: "npm:^2.0.0" + pino-abstract-transport: "npm:^3.0.0" pump: "npm:^3.0.0" secure-json-parse: "npm:^4.0.0" sonic-boom: "npm:^4.0.1" strip-json-comments: "npm:^5.0.2" bin: pino-pretty: bin.js - checksum: 10c0/4d8e7472e37bdb6e0d6d7d34f25f65ced46c0f64a9579bb805602321caf1c0b10359f89a1ee9742bea875f411a02ce7c19730f7a1e5387dfcfd10ff5c9804709 + checksum: 10c0/36fa382521a893290c8f6a5b2ddc28dfb87fda1d161adb6b97d80bf7d24184970d0a7eab6f8ee45c39aff4b2ec3b2e533c756899798adc270010f34ba4411063 languageName: node linkType: hard @@ -11091,11 +11114,11 @@ __metadata: linkType: hard "prettier@npm:^3.3.3": - version: 3.6.2 - resolution: "prettier@npm:3.6.2" + version: 3.7.4 + resolution: "prettier@npm:3.7.4" bin: prettier: bin/prettier.cjs - checksum: 10c0/488cb2f2b99ec13da1e50074912870217c11edaddedeadc649b1244c749d15ba94e846423d062e2c4c9ae683e2d65f754de28889ba06e697ac4f988d44f45812 + checksum: 10c0/9675d2cd08eacb1faf1d1a2dbfe24bfab6a912b059fc9defdb380a408893d88213e794a40a2700bd29b140eb3172e0b07c852853f6e22f16f3374659a1a13389 languageName: node linkType: hard @@ -12186,8 +12209,8 @@ __metadata: linkType: hard "sass@npm:^1.62.1, sass@npm:^1.70.0": - version: 1.94.2 - resolution: "sass@npm:1.94.2" + version: 1.96.0 + resolution: "sass@npm:1.96.0" dependencies: "@parcel/watcher": "npm:^2.4.1" chokidar: "npm:^4.0.0" @@ -12198,7 +12221,7 @@ __metadata: optional: true bin: sass: sass.js - checksum: 10c0/49a656dfab58299165ef94e71483a333972daee68c49fa542858d4912accdfb1707338226a165b1a2dfcdb2509fcda5a5b4f3780d14e49b6d38d93c8043475d3 + checksum: 10c0/a932054bcee6935757417af6072d31b65ce3557798a53351b3e1369d7f06e24b0ec211e1617bdaaee998b429a44bf0f52acd240fd47f88422d5bc241eeb71672 languageName: node linkType: hard @@ -13069,10 +13092,11 @@ __metadata: linkType: hard "stylelint@npm:^16.19.1": - version: 16.26.0 - resolution: "stylelint@npm:16.26.0" + version: 16.26.1 + resolution: "stylelint@npm:16.26.1" dependencies: "@csstools/css-parser-algorithms": "npm:^3.0.5" + "@csstools/css-syntax-patches-for-csstree": "npm:^1.0.19" "@csstools/css-tokenizer": "npm:^3.0.4" "@csstools/media-query-list-parser": "npm:^4.0.3" "@csstools/selector-specificity": "npm:^5.0.0" @@ -13085,7 +13109,7 @@ __metadata: debug: "npm:^4.4.3" fast-glob: "npm:^3.3.3" fastest-levenshtein: "npm:^1.0.16" - file-entry-cache: "npm:^11.1.0" + file-entry-cache: "npm:^11.1.1" global-modules: "npm:^2.0.0" globby: "npm:^11.1.0" globjoin: "npm:^0.1.4" @@ -13112,7 +13136,7 @@ __metadata: write-file-atomic: "npm:^5.0.1" bin: stylelint: bin/stylelint.mjs - checksum: 10c0/6f501ff051aee4fc7713635c98bf6837f889b22fe86152cfed20365ffeee0acf9d751f94ff265433b532b2a1ab7a228fc1fda3f507859acb57a689268939553d + checksum: 10c0/3805dfe868abdcc5a62e5726eebe5e950432cfadfc5b47c2f103ef4dede8ee1eb8a1247c9ceb01a1739c0aba68865d79899d33a707256365bb2004664524908b languageName: node linkType: hard @@ -13268,16 +13292,16 @@ __metadata: languageName: node linkType: hard -"tesseract.js-core@npm:^6.0.0": - version: 6.0.0 - resolution: "tesseract.js-core@npm:6.0.0" - checksum: 10c0/c04be8bbaa296be658664496754f21e857bdffff84113f08adf02f03a1f84596d68b3542ed2fda4a6dc138abb84b09b30ab07c04ee5950879e780876d343955f +"tesseract.js-core@npm:^7.0.0": + version: 7.0.0 + resolution: "tesseract.js-core@npm:7.0.0" + checksum: 10c0/c1afee9f8aecf994bc4714fd879e57d04b995849345532872bdc3d8c82a59c4ebbb0acde14d2b24e6a3aec27cafee3d18931f2744496d603ae36241290108e17 languageName: node linkType: hard -"tesseract.js@npm:^6.0.0": - version: 6.0.1 - resolution: "tesseract.js@npm:6.0.1" +"tesseract.js@npm:^7.0.0": + version: 7.0.0 + resolution: "tesseract.js@npm:7.0.0" dependencies: bmp-js: "npm:^0.1.0" idb-keyval: "npm:^6.2.0" @@ -13285,10 +13309,10 @@ __metadata: node-fetch: "npm:^2.6.9" opencollective-postinstall: "npm:^2.0.3" regenerator-runtime: "npm:^0.13.3" - tesseract.js-core: "npm:^6.0.0" - wasm-feature-detect: "npm:^1.2.11" + tesseract.js-core: "npm:^7.0.0" + wasm-feature-detect: "npm:^1.8.0" zlibjs: "npm:^0.3.1" - checksum: 10c0/1d73bb1fbc00c8629756d9594989d8bbfabda657a8cad84922ad68eb0f073148c82845bf71a882e5d2427a46edb5a470356864e60562c7a8442bddd70251435a + checksum: 10c0/daf5b153a9a06e0ab3365b33f4cc323e375d3a8b86a7df98031c19047623451aa3bfb293c295b0ebecd5c0781e42e43469d93ed4e7ba8a868b7a457120e609a1 languageName: node linkType: hard @@ -13329,10 +13353,10 @@ __metadata: languageName: node linkType: hard -"tinyexec@npm:^0.3.2": - version: 0.3.2 - resolution: "tinyexec@npm:0.3.2" - checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 +"tinyexec@npm:^1.0.2": + version: 1.0.2 + resolution: "tinyexec@npm:1.0.2" + checksum: 10c0/1261a8e34c9b539a9aae3b7f0bb5372045ff28ee1eba035a2a059e532198fe1a182ec61ac60fa0b4a4129f0c4c4b1d2d57355b5cb9aa2d17ac9454ecace502ee languageName: node linkType: hard @@ -14132,25 +14156,25 @@ __metadata: linkType: hard "vitest@npm:^4.0.5": - version: 4.0.13 - resolution: "vitest@npm:4.0.13" + version: 4.0.15 + resolution: "vitest@npm:4.0.15" dependencies: - "@vitest/expect": "npm:4.0.13" - "@vitest/mocker": "npm:4.0.13" - "@vitest/pretty-format": "npm:4.0.13" - "@vitest/runner": "npm:4.0.13" - "@vitest/snapshot": "npm:4.0.13" - "@vitest/spy": "npm:4.0.13" - "@vitest/utils": "npm:4.0.13" - debug: "npm:^4.4.3" + "@vitest/expect": "npm:4.0.15" + "@vitest/mocker": "npm:4.0.15" + "@vitest/pretty-format": "npm:4.0.15" + "@vitest/runner": "npm:4.0.15" + "@vitest/snapshot": "npm:4.0.15" + "@vitest/spy": "npm:4.0.15" + "@vitest/utils": "npm:4.0.15" es-module-lexer: "npm:^1.7.0" expect-type: "npm:^1.2.2" magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" pathe: "npm:^2.0.3" picomatch: "npm:^4.0.3" std-env: "npm:^3.10.0" tinybench: "npm:^2.9.0" - tinyexec: "npm:^0.3.2" + tinyexec: "npm:^1.0.2" tinyglobby: "npm:^0.2.15" tinyrainbow: "npm:^3.0.3" vite: "npm:^6.0.0 || ^7.0.0" @@ -14158,12 +14182,11 @@ __metadata: peerDependencies: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 - "@types/debug": ^4.1.12 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.0.13 - "@vitest/browser-preview": 4.0.13 - "@vitest/browser-webdriverio": 4.0.13 - "@vitest/ui": 4.0.13 + "@vitest/browser-playwright": 4.0.15 + "@vitest/browser-preview": 4.0.15 + "@vitest/browser-webdriverio": 4.0.15 + "@vitest/ui": 4.0.15 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -14171,8 +14194,6 @@ __metadata: optional: true "@opentelemetry/api": optional: true - "@types/debug": - optional: true "@types/node": optional: true "@vitest/browser-playwright": @@ -14189,7 +14210,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/8582ab1848d5d7dbbac0b3a5eae2625f44d0db887f73da2ee8f588fb13c66fe8ea26dac05c26ebb43673b735bc246764f52969f7c7e25455dfb7c6274659ae2c + checksum: 10c0/fd57913dbcba81b67ca67bae37f0920f2785a60939a9029a82ebb843253f7a67f93f2c959cb90bb23a57055c0256ec0a6059ec9a10c129e8912c09b6e407242b languageName: node linkType: hard @@ -14211,7 +14232,7 @@ __metadata: languageName: node linkType: hard -"wasm-feature-detect@npm:^1.2.11": +"wasm-feature-detect@npm:^1.8.0": version: 1.8.0 resolution: "wasm-feature-detect@npm:1.8.0" checksum: 10c0/2cb43e91bbf7aa7c121bc76b3133de3ab6dc4f482acc1d2dc46c528e8adb7a51c72df5c2aacf1d219f113c04efd1706f18274d5790542aa5dd49e0644e3ee665