Merge commit 'cbe135210305c0ce4455130c4e75680e53ff1425' into glitch-soc/merge-upstream

Conflicts:
- `app/views/wrapstodon/show.html.haml`:
  Conflict caused by glitch-soc's theming system.
  Updated upstream changes.
This commit is contained in:
Claire 2025-12-15 20:22:20 +01:00
commit 1fdb48c668
65 changed files with 958 additions and 344 deletions

2
.nvmrc
View File

@ -1 +1 @@
24.11 24.12

View File

@ -11,6 +11,11 @@ import type { Preview } from '@storybook/react-vite';
import { initialize, mswLoader } from 'msw-storybook-addon'; import { initialize, mswLoader } from 'msw-storybook-addon';
import { action } from 'storybook/actions'; import { action } from 'storybook/actions';
import {
importCustomEmojiData,
importLegacyShortcodes,
importEmojiData,
} from '@/mastodon/features/emoji/loader';
import type { LocaleData } from '@/mastodon/locales'; import type { LocaleData } from '@/mastodon/locales';
import { reducerWithInitialState } from '@/mastodon/reducers'; import { reducerWithInitialState } from '@/mastodon/reducers';
import { defaultMiddleware } from '@/mastodon/store/store'; import { defaultMiddleware } from '@/mastodon/store/store';
@ -127,7 +132,12 @@ const preview: Preview = {
</MemoryRouter> </MemoryRouter>
), ),
], ],
loaders: [mswLoader], loaders: [
mswLoader,
importCustomEmojiData,
importLegacyShortcodes,
({ globals: { locale } }) => importEmojiData(locale as string),
],
parameters: { parameters: {
layout: 'centered', layout: 'centered',

View File

@ -773,7 +773,7 @@ GEM
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.33.4) rubocop-rails (2.34.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
@ -792,7 +792,7 @@ GEM
ruby-saml (1.18.1) ruby-saml (1.18.1)
nokogiri (>= 1.13.10) nokogiri (>= 1.13.10)
rexml rexml
ruby-vips (2.2.5) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.2.2) rubyzip (3.2.2)

View File

@ -39,7 +39,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
return @body if defined?(@body) return @body if defined?(@body)
@body = request.body.read @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) request.body.rewind if request.body.respond_to?(:rewind)

View File

@ -41,8 +41,8 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
if current_account&.id != @status.account_id if current_account&.id != @status.account_id
domains = @statuses.filter_map(&:account_domain).uniq domains = @statuses.filter_map(&:account_domain).uniq
account_ids = @statuses.map(&:account_id).uniq account_ids = @statuses.map(&:account_id).uniq
relations = current_account&.relations_map(account_ids, domains) || {} current_account&.preload_relations!(account_ids, domains)
@statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? } @statuses.reject! { |status| StatusFilter.new(status, current_account).filtered? }
end end
end end

View File

@ -11,6 +11,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
before_action :set_collection before_action :set_collection
before_action :set_account, only: [:create] before_action :set_account, only: [:create]
before_action :set_collection_item, only: [:destroy]
after_action :verify_authorized after_action :verify_authorized
@ -23,6 +24,14 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
render json: @item, serializer: REST::CollectionItemSerializer render json: @item, serializer: REST::CollectionItemSerializer
end end
def destroy
authorize @collection, :update?
@collection_item.destroy
head 200
end
private private
def set_collection def set_collection
@ -35,6 +44,10 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
@account = Account.find(params[:account_id]) @account = Account.find(params[:account_id])
end end
def set_collection_item
@collection_item = @collection.collection_items.find(params[:id])
end
def check_feature_enabled def check_feature_enabled
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
end end

View File

@ -12,7 +12,7 @@ class WrapstodonController < ApplicationController
skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode? skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode?
def show def show
expires_in 10.seconds, public: true if current_account.nil? expires_in 10.minutes, public: true if current_account.nil?
end end
private private

View File

@ -2,7 +2,7 @@
module DatabaseHelper module DatabaseHelper
def replica_enabled? 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 end
module_function :replica_enabled? module_function :replica_enabled?

View File

@ -2,15 +2,19 @@
module WrapstodonHelper module WrapstodonHelper
def render_wrapstodon_share_data(report) def render_wrapstodon_share_data(report)
json = ActiveModelSerializers::SerializableResource.new( payload = ActiveModelSerializers::SerializableResource.new(
AnnualReportsPresenter.new([report]), AnnualReportsPresenter.new([report]),
serializer: REST::AnnualReportsSerializer, serializer: REST::AnnualReportsSerializer,
scope: nil, scope: nil,
scope_name: :current_user 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 # 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 # rubocop:enable Rails/OutputSafety
end end
end end

View File

@ -256,9 +256,8 @@ async function mountReactComponent(element: Element) {
const componentProps = JSON.parse(stringProps) as object; const componentProps = JSON.parse(stringProps) as object;
const { default: AdminComponent } = await import( const { default: AdminComponent } =
'@/mastodon/containers/admin_component' await import('@/mastodon/containers/admin_component');
);
const { default: Component } = (await import( const { default: Component } = (await import(
`@/mastodon/components/admin/${componentName}.jsx` `@/mastodon/components/admin/${componentName}.jsx`

View File

@ -25,7 +25,7 @@ function loaded() {
const initialState = JSON.parse( const initialState = JSON.parse(
propsNode.textContent, propsNode.textContent,
) as ApiAnnualReportResponse; ) as ApiAnnualReportResponse & { me?: string };
const report = initialState.annual_reports[0]; const report = initialState.annual_reports[0];
if (!report) { if (!report) {
@ -35,7 +35,10 @@ function loaded() {
// Set up store // Set up store
store.dispatch( store.dispatch(
hydrateStore({ hydrateStore({
meta: { locale: document.documentElement.lang }, meta: {
locale: document.documentElement.lang,
me: initialState.me,
},
accounts: initialState.accounts, accounts: initialState.accounts,
}), }),
); );

View File

@ -102,8 +102,7 @@ export interface ApiAccountWarningJSON {
appeal: unknown; appeal: unknown;
} }
interface ModerationWarningNotificationGroupJSON interface ModerationWarningNotificationGroupJSON extends BaseNotificationGroupJSON {
extends BaseNotificationGroupJSON {
type: 'moderation_warning'; type: 'moderation_warning';
moderation_warning: ApiAccountWarningJSON; moderation_warning: ApiAccountWarningJSON;
} }
@ -123,14 +122,12 @@ export interface ApiAccountRelationshipSeveranceEventJSON {
created_at: string; created_at: string;
} }
interface AccountRelationshipSeveranceNotificationGroupJSON interface AccountRelationshipSeveranceNotificationGroupJSON extends BaseNotificationGroupJSON {
extends BaseNotificationGroupJSON {
type: 'severed_relationships'; type: 'severed_relationships';
event: ApiAccountRelationshipSeveranceEventJSON; event: ApiAccountRelationshipSeveranceEventJSON;
} }
interface AccountRelationshipSeveranceNotificationJSON interface AccountRelationshipSeveranceNotificationJSON extends BaseNotificationJSON {
extends BaseNotificationJSON {
type: 'severed_relationships'; type: 'severed_relationships';
event: ApiAccountRelationshipSeveranceEventJSON; event: ApiAccountRelationshipSeveranceEventJSON;
} }

View File

@ -5,8 +5,10 @@ import classNames from 'classnames';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
interface BaseProps interface BaseProps extends Omit<
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> { React.ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> {
block?: boolean; block?: boolean;
secondary?: boolean; secondary?: boolean;
plain?: boolean; plain?: boolean;

View File

@ -309,7 +309,7 @@ interface DropdownProps<Item extends object | null = MenuItem> {
renderItem?: RenderItemFn<Item>; renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>; renderHeader?: RenderHeaderFn<Item>;
onOpen?: // Must use a union type for the full function as a union with void is not allowed. 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); | ((event: React.MouseEvent | React.KeyboardEvent) => boolean);
onItemClick?: ItemClickFn<Item>; onItemClick?: ItemClickFn<Item>;
} }

View File

@ -2,8 +2,6 @@ import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { importCustomEmojiData } from '@/mastodon/features/emoji/loader';
import { Emoji } from './index'; import { Emoji } from './index';
type EmojiProps = ComponentProps<typeof Emoji> & { state: string }; type EmojiProps = ComponentProps<typeof Emoji> & { state: string };
@ -38,7 +36,6 @@ const meta = {
}, },
}, },
render(args) { render(args) {
void importCustomEmojiData();
return <Emoji {...args} />; return <Emoji {...args} />;
}, },
} satisfies Meta<EmojiProps>; } satisfies Meta<EmojiProps>;
@ -54,3 +51,9 @@ export const CustomEmoji: Story = {
code: ':custom:', code: ':custom:',
}, },
}; };
export const LegacyEmoji: Story = {
args: {
code: ':copyright:',
},
};

View File

@ -3,7 +3,10 @@ import { useContext, useEffect, useState } from 'react';
import classNames from 'classnames'; 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 { useEmojiAppState } from '@/mastodon/features/emoji/mode';
import { import {
emojiToInversionClassName, emojiToInversionClassName,
@ -47,8 +50,6 @@ export const Emoji: FC<EmojiProps> = ({
const animate = useContext(AnimateEmojiContext); const animate = useContext(AnimateEmojiContext);
const inversionClass = emojiToInversionClassName(code);
const fallback = showFallback ? code : null; const fallback = showFallback ? code : null;
// If the code is invalid or we otherwise know it's not valid, show the fallback. // If the code is invalid or we otherwise know it's not valid, show the fallback.
@ -56,10 +57,6 @@ export const Emoji: FC<EmojiProps> = ({
return fallback; return fallback;
} }
if (!shouldRenderImage(state, appState.mode)) {
return code;
}
if (!isStateLoaded(state)) { if (!isStateLoaded(state)) {
if (showLoading) { if (showLoading) {
return <span className='emojione emoji-loading' title={code} />; return <span className='emojione emoji-loading' title={code} />;
@ -67,6 +64,17 @@ export const Emoji: FC<EmojiProps> = ({
return fallback; 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) { if (state.type === EMOJI_TYPE_CUSTOM) {
const shortcode = `:${state.code}:`; const shortcode = `:${state.code}:`;
return ( return (

View File

@ -12,6 +12,7 @@ import replier from '@/images/archetypes/replier.png';
import space_elements from '@/images/archetypes/space_elements.png'; import space_elements from '@/images/archetypes/space_elements.png';
import { Avatar } from '@/mastodon/components/avatar'; import { Avatar } from '@/mastodon/components/avatar';
import { Button } from '@/mastodon/components/button'; import { Button } from '@/mastodon/components/button';
import { DisplayName } from '@/mastodon/components/display_name';
import { me } from '@/mastodon/initial_state'; import { me } from '@/mastodon/initial_state';
import type { Account } from '@/mastodon/models/account'; import type { Account } from '@/mastodon/models/account';
import type { import type {
@ -137,9 +138,6 @@ export const Archetype: React.FC<{
? archetypeSelfDescriptions ? archetypeSelfDescriptions
: archetypePublicDescriptions; : 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 ( return (
<div <div
className={classNames(styles.box, styles.archetype)} className={classNames(styles.box, styles.archetype)}
@ -182,7 +180,9 @@ export const Archetype: React.FC<{
<FormattedMessage <FormattedMessage
id='annual_report.summary.archetype.title_public' id='annual_report.summary.archetype.title_public'
defaultMessage="{name}'s archetype" defaultMessage="{name}'s archetype"
values={{ name }} values={{
name: <DisplayName variant='simple' account={account} />,
}}
/> />
)} )}
</h2> </h2>
@ -199,7 +199,7 @@ export const Archetype: React.FC<{
<p> <p>
{isRevealed ? ( {isRevealed ? (
intl.formatMessage(descriptions[archetype], { intl.formatMessage(descriptions[archetype], {
name, name: <DisplayName variant='simple' account={account} />,
}) })
) : ( ) : (
<FormattedMessage <FormattedMessage

View File

@ -10,7 +10,6 @@ import classNames from 'classnames/bind';
import { closeModal } from '@/mastodon/actions/modal'; import { closeModal } from '@/mastodon/actions/modal';
import { IconButton } from '@/mastodon/components/icon_button'; import { IconButton } from '@/mastodon/components/icon_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { me } from '@/mastodon/initial_state';
import { import {
createAppSelector, createAppSelector,
useAppDispatch, useAppDispatch,
@ -30,9 +29,6 @@ const moduleClassNames = classNames.bind(styles);
const accountSelector = createAppSelector( const accountSelector = createAppSelector(
[(state) => state.accounts, (state) => state.annualReport.report], [(state) => state.accounts, (state) => state.annualReport.report],
(accounts, report) => { (accounts, report) => {
if (me) {
return accounts.get(me);
}
if (report?.schema_version === 2) { if (report?.schema_version === 2) {
return accounts.get(report.account_id); return accounts.get(report.account_id);
} }
@ -109,7 +105,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
{topHashtag && ( {topHashtag && (
<MostUsedHashtag <MostUsedHashtag
hashtag={topHashtag} hashtag={topHashtag}
name={account?.display_name} account={account}
context={context} context={context}
/> />
)} )}

View File

@ -2,15 +2,17 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; 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 type { NameAndCount } from 'mastodon/models/annual_report';
import styles from './index.module.scss'; import styles from './index.module.scss';
export const MostUsedHashtag: React.FC<{ export const MostUsedHashtag: React.FC<{
hashtag: NameAndCount; hashtag: NameAndCount;
name: string | undefined;
context: 'modal' | 'standalone'; context: 'modal' | 'standalone';
}> = ({ hashtag, name, context }) => { account?: Account;
}> = ({ hashtag, context, account }) => {
return ( return (
<div <div
className={classNames(styles.box, styles.mostUsedHashtag, styles.content)} className={classNames(styles.box, styles.mostUsedHashtag, styles.content)}
@ -25,20 +27,22 @@ export const MostUsedHashtag: React.FC<{
<div className={styles.statExtraLarge}>#{hashtag.name}</div> <div className={styles.statExtraLarge}>#{hashtag.name}</div>
<p> <p>
{context === 'modal' ? ( {context === 'modal' && (
<FormattedMessage <FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count' id='annual_report.summary.most_used_hashtag.used_count'
defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.' defaultMessage='You included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count }} values={{ count: hashtag.count }}
/> />
) : ( )}
name && ( {context !== 'modal' && account && (
<FormattedMessage <FormattedMessage
id='annual_report.summary.most_used_hashtag.used_count_public' id='annual_report.summary.most_used_hashtag.used_count_public'
defaultMessage='{name} included this hashtag in {count, plural, one {one post} other {# posts}}.' defaultMessage='{name} included this hashtag in {count, plural, one {one post} other {# posts}}.'
values={{ count: hashtag.count, name }} values={{
/> count: hashtag.count,
) name: <DisplayName variant='simple' account={account} />,
}}
/>
)} )}
</p> </p>
</div> </div>

View File

@ -3,12 +3,13 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { IconLogo } from '@/mastodon/components/logo'; import { IconLogo } from '@/mastodon/components/logo';
import { me } from '@/mastodon/initial_state'; import { useAppSelector } from '@/mastodon/store';
import { AnnualReport } from './index'; import { AnnualReport } from './index';
import classes from './shared_page.module.scss'; import classes from './shared_page.module.scss';
export const WrapstodonSharedPage: FC = () => { export const WrapstodonSharedPage: FC = () => {
const isLoggedIn = useAppSelector((state) => !!state.meta.get('me'));
return ( return (
<main className={classes.wrapper}> <main className={classes.wrapper}>
<AnnualReport /> <AnnualReport />
@ -23,7 +24,7 @@ export const WrapstodonSharedPage: FC = () => {
<a href='https://joinmastodon.org'> <a href='https://joinmastodon.org'>
<FormattedMessage id='footer.about' defaultMessage='About' /> <FormattedMessage id='footer.about' defaultMessage='About' />
</a> </a>
{!me && ( {!isLoggedIn && (
<a href='https://joinmastodon.org/servers'> <a href='https://joinmastodon.org/servers'>
<FormattedMessage <FormattedMessage
id='annual_report.shared_page.sign_up' id='annual_report.shared_page.sign_up'

View File

@ -399,9 +399,10 @@ export const LanguageDropdown: React.FC = () => {
} }
return ( return (
<div ref={targetRef}> <>
<button <button
type='button' type='button'
ref={targetRef}
title={intl.formatMessage(messages.changeLanguage)} title={intl.formatMessage(messages.changeLanguage)}
aria-expanded={open} aria-expanded={open}
onClick={handleToggle} onClick={handleToggle}
@ -438,6 +439,6 @@ export const LanguageDropdown: React.FC = () => {
</div> </div>
)} )}
</Overlay> </Overlay>
</div> </>
); );
}; };

View File

@ -23,6 +23,10 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji';
export const EMOJI_TYPE_UNICODE = 'unicode'; export const EMOJI_TYPE_UNICODE = 'unicode';
export const EMOJI_TYPE_CUSTOM = 'custom'; 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 = [ export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1 '🎱', // 1F3B1
'🐜', // 1F41C '🐜', // 1F41C

View File

@ -1,7 +1,8 @@
import { IDBFactory } from 'fake-indexeddb'; import { IDBFactory } from 'fake-indexeddb';
import { unicodeEmojiFactory } from '@/testing/factories'; import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { import {
putEmojiData, putEmojiData,
loadEmojiByHexcode, loadEmojiByHexcode,
@ -9,6 +10,11 @@ import {
searchEmojisByTag, searchEmojisByTag,
testClear, testClear,
testGet, testGet,
putCustomEmojiData,
putLegacyShortcodes,
loadLegacyShortcodesByShortcode,
loadLatestEtag,
putLatestEtag,
} from './database'; } from './database';
describe('emoji database', () => { describe('emoji database', () => {
@ -16,6 +22,7 @@ describe('emoji database', () => {
testClear(); testClear();
indexedDB = new IDBFactory(); indexedDB = new IDBFactory();
}); });
describe('putEmojiData', () => { describe('putEmojiData', () => {
test('adds to loaded locales', async () => { test('adds to loaded locales', async () => {
const { loadedLocales } = await testGet(); 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', () => { describe('loadEmojiByHexcode', () => {
test('throws if the locale is not loaded', async () => { test('throws if the locale is not loaded', async () => {
await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError( await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError(
@ -136,4 +166,58 @@ describe('emoji database', () => {
expect(actual).toHaveLength(0); 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();
});
});
}); });

View File

@ -1,8 +1,9 @@
import { SUPPORTED_LOCALES } from 'emojibase'; import { SUPPORTED_LOCALES } from 'emojibase';
import type { Locale } from 'emojibase'; import type { Locale, ShortcodesDataset } from 'emojibase';
import type { DBSchema, IDBPDatabase } from 'idb'; import type { DBSchema, IDBPDatabase } from 'idb';
import { openDB } from 'idb'; import { openDB } from 'idb';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { import type {
CustomEmojiData, CustomEmojiData,
@ -19,6 +20,17 @@ interface EmojiDB extends LocaleTables, DBSchema {
category: string; category: string;
}; };
}; };
shortcodes: {
key: string;
value: {
hexcode: string;
shortcodes: string[];
};
indexes: {
hexcode: string;
shortcodes: string[];
};
};
etags: { etags: {
key: LocaleOrCustom; key: LocaleOrCustom;
value: string; value: string;
@ -33,13 +45,14 @@ interface LocaleTable {
label: string; label: string;
order: number; order: number;
tags: string[]; tags: string[];
shortcodes: string[];
}; };
} }
type LocaleTables = Record<Locale, LocaleTable>; type LocaleTables = Record<Locale, LocaleTable>;
type Database = IDBPDatabase<EmojiDB>; type Database = IDBPDatabase<EmojiDB>;
const SCHEMA_VERSION = 1; const SCHEMA_VERSION = 2;
const loadedLocales = new Set<Locale>(); const loadedLocales = new Set<Locale>();
@ -52,28 +65,76 @@ const loadDB = (() => {
// Actually load the DB. // Actually load the DB.
async function initDB() { async function initDB() {
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, { const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) { upgrade(database, oldVersion, newVersion, trx) {
const customTable = database.createObjectStore('custom', { if (!database.objectStoreNames.contains('custom')) {
keyPath: 'shortcode', const customTable = database.createObjectStore('custom', {
autoIncrement: false, keyPath: 'shortcode',
}); autoIncrement: false,
customTable.createIndex('category', 'category'); });
customTable.createIndex('category', 'category');
}
database.createObjectStore('etags'); if (!database.objectStoreNames.contains('etags')) {
database.createObjectStore('etags');
}
for (const locale of SUPPORTED_LOCALES) { 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', keyPath: 'hexcode',
autoIncrement: false, autoIncrement: false,
}); });
localeTable.createIndex('group', 'group'); shortcodeTable.createIndex('hexcode', 'hexcode');
localeTable.createIndex('label', 'label'); shortcodeTable.createIndex('shortcodes', 'shortcodes', {
localeTable.createIndex('order', 'order'); multiEntry: true,
localeTable.createIndex('tags', 'tags', { 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); await syncLocales(db);
log('Loaded database version %d', db.version);
return db; return db;
} }
@ -107,6 +168,20 @@ export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
await trx.done; 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) { export async function putLatestEtag(etag: string, localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString); const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB(); const db = await loadDB();
@ -161,6 +236,15 @@ export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
return results.filter((emoji) => shortcodes.includes(emoji.shortcode)); 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) { export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString); const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB(); const db = await loadDB();
@ -168,6 +252,15 @@ export async function loadLatestEtag(localeString: string) {
if (!rowCount) { if (!rowCount) {
return null; // No data for this locale, return null even if there is an etag. 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); const etag = await db.get('etags', locale);
return etag ?? null; return etag ?? null;
} }

View File

@ -1,5 +1,9 @@
import type { Locale } from 'emojibase';
import { initialState } from '@/mastodon/initial_state'; 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 { toSupportedLocale } from './locale';
import type { LocaleOrCustom } from './types'; import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils'; import { emojiLogger } from './utils';
@ -36,12 +40,8 @@ export function initializeEmoji() {
log('worker ready, loading data'); log('worker ready, loading data');
clearTimeout(timeoutId); clearTimeout(timeoutId);
messageWorker('custom'); messageWorker('custom');
messageWorker('shortcodes');
void loadEmojiLocale(userLocale); 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 { } else {
log('got worker message: %s', message); log('got worker message: %s', message);
} }
@ -58,20 +58,23 @@ async function fallbackLoad() {
if (emojis) { if (emojis) {
log('loaded %d custom emojis', emojis.length); log('loaded %d custom emojis', emojis.length);
} }
await loadEmojiLocale(userLocale); const shortcodes = await importLegacyShortcodes();
if (userLocale !== 'en') { if (shortcodes.length) {
await loadEmojiLocale('en'); log('loaded %d legacy shortcodes', shortcodes.length);
} }
await loadEmojiLocale(userLocale);
} }
async function loadEmojiLocale(localeString: string) { async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString); const locale = toSupportedLocale(localeString);
const { importEmojiData, localeToPath } = await import('./loader'); const { importEmojiData, localeToEmojiPath: localeToPath } =
await import('./loader');
if (worker) { if (worker) {
const path = await localeToPath(locale); const path = await localeToPath(locale);
const shortcodesPath = await localeToShortcodesPath(locale);
log('asking worker to load locale %s from %s', locale, path); log('asking worker to load locale %s from %s', locale, path);
messageWorker(locale, path); messageWorker(locale, path, shortcodesPath);
} else { } else {
const emojis = await importEmojiData(locale); const emojis = await importEmojiData(locale);
if (emojis) { 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) { if (!worker) {
return; return;
} }
worker.postMessage({ locale, path }); worker.postMessage({ locale, path, shortcodes });
} }

View File

@ -1,16 +1,26 @@
import { flattenEmojiData } from 'emojibase'; import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji, Locale } from 'emojibase'; import type {
CompactEmoji,
FlatCompactEmoji,
Locale,
ShortcodesDataset,
} from 'emojibase';
import { import {
putEmojiData, putEmojiData,
putCustomEmojiData, putCustomEmojiData,
loadLatestEtag, loadLatestEtag,
putLatestEtag, putLatestEtag,
putLegacyShortcodes,
} from './database'; } from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { CustomEmojiData } from './types'; 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); const locale = toSupportedLocale(localeString);
// Validate the provided path. // Validate the provided path.
@ -18,14 +28,42 @@ export async function importEmojiData(localeString: string, path?: string) {
throw new Error('Invalid path for emoji data'); throw new Error('Invalid path for emoji data');
} else { } else {
// Otherwise get the path if not provided. // Otherwise get the path if not provided.
path ??= await localeToPath(locale); path ??= await localeToEmojiPath(locale);
} }
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale, path); const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale, path);
if (!emojis) { if (!emojis) {
return; 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<ShortcodesDataset>(
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); await putEmojiData(flattenedEmojis, locale);
return flattenedEmojis; return flattenedEmojis;
} }
@ -42,32 +80,77 @@ export async function importCustomEmojiData() {
return emojis; return emojis;
} }
const modules = import.meta.glob<string>( export async function importLegacyShortcodes() {
'../../../../../node_modules/emojibase-data/**/compact.json', const { default: shortcodesPath } =
{ await import('emojibase-data/en/shortcodes/iamcal.json?url');
query: '?url', const response = await fetch(shortcodesPath);
import: 'default', if (!response.ok) {
}, throw new Error(
); `Failed to fetch legacy shortcodes data: ${response.statusText}`,
);
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}`);
} }
return modules[key](); const shortcodesData = (await response.json()) as ShortcodesDataset;
await putLegacyShortcodes(shortcodesData);
return Object.keys(shortcodesData);
} }
export async function fetchAndCheckEtag<ResultType extends object[]>( const emojiModules = new Map(
Object.entries(
import.meta.glob<string>(
'../../../../../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<string>(
'../../../../../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<ResultType extends object[] | object>(
localeString: string, localeString: string,
path: string, path: string,
checkEtag = true,
): Promise<ResultType | null> { ): Promise<ResultType | null> {
const locale = toSupportedLocaleOrCustom(localeString); const locale = toSupportedLocaleOrCustom(localeString);
// Use location.origin as this script may be loaded from a CDN domain. // Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(path, location.origin); const url = new URL(path, location.origin);
const oldEtag = await loadLatestEtag(locale); const oldEtag = checkEtag ? await loadLatestEtag(locale) : null;
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -85,13 +168,10 @@ export async function fetchAndCheckEtag<ResultType extends object[]>(
} }
const data = (await response.json()) as ResultType; 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 // Store the ETag for future requests
const etag = response.headers.get('ETag'); const etag = response.headers.get('ETag');
if (etag) { if (etag && checkEtag) {
await putLatestEtag(etag, localeString); await putLatestEtag(etag, localeString);
} }

View File

@ -7,6 +7,7 @@ import {
stringToEmojiState, stringToEmojiState,
tokenizeText, tokenizeText,
} from './render'; } from './render';
import type { EmojiStateCustom, EmojiStateUnicode } from './types';
describe('tokenizeText', () => { describe('tokenizeText', () => {
test('returns an array of text to be a single token', () => { test('returns an array of text to be a single token', () => {
@ -120,13 +121,24 @@ describe('loadEmojiDataToState', () => {
const dbCall = vi const dbCall = vi
.spyOn(db, 'loadEmojiByHexcode') .spyOn(db, 'loadEmojiByHexcode')
.mockResolvedValue(unicodeEmojiFactory()); .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'); const result = await loadEmojiDataToState(unicodeState, 'en');
expect(dbCall).toHaveBeenCalledWith('1F60A', 'en'); expect(dbCall).toHaveBeenCalledWith('1F60A', 'en');
expect(dbLegacyCall).toHaveBeenCalledWith('1F60A');
expect(result).toEqual({ expect(result).toEqual({
type: 'unicode', type: 'unicode',
code: '1F60A', code: '1F60A',
data: unicodeEmojiFactory(), data: unicodeEmojiFactory(),
shortcode: 'legacy_code',
}); });
}); });
@ -134,7 +146,10 @@ describe('loadEmojiDataToState', () => {
const dbCall = vi const dbCall = vi
.spyOn(db, 'loadCustomEmojiByShortcode') .spyOn(db, 'loadCustomEmojiByShortcode')
.mockResolvedValueOnce(customEmojiFactory()); .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'); const result = await loadEmojiDataToState(customState, 'en');
expect(dbCall).toHaveBeenCalledWith('smile'); expect(dbCall).toHaveBeenCalledWith('smile');
expect(result).toEqual({ 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 () => { test('returns null if unicode emoji not found in database', async () => {
vi.spyOn(db, 'loadEmojiByHexcode').mockResolvedValueOnce(undefined); 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'); const result = await loadEmojiDataToState(unicodeState, 'en');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test('returns null if custom emoji not found in database', async () => { test('returns null if custom emoji not found in database', async () => {
vi.spyOn(db, 'loadCustomEmojiByShortcode').mockResolvedValueOnce(undefined); 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'); const result = await loadEmojiDataToState(customState, 'en');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -167,7 +213,10 @@ describe('loadEmojiDataToState', () => {
.spyOn(console, 'warn') .spyOn(console, 'warn')
.mockImplementationOnce(() => null); .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'); const result = await loadEmojiDataToState(unicodeState, 'en');
expect(dbCall).toHaveBeenCalledTimes(2); expect(dbCall).toHaveBeenCalledTimes(2);

View File

@ -7,6 +7,7 @@ import {
import { import {
loadCustomEmojiByShortcode, loadCustomEmojiByShortcode,
loadEmojiByHexcode, loadEmojiByHexcode,
loadLegacyShortcodesByShortcode,
LocaleNotLoadedError, LocaleNotLoadedError,
} from './database'; } from './database';
import { importEmojiData } from './loader'; import { importEmojiData } from './loader';
@ -116,13 +117,20 @@ export async function loadEmojiDataToState(
// First, try to load the data from IndexedDB. // First, try to load the data from IndexedDB.
try { try {
const legacyCode = await loadLegacyShortcodesByShortcode(state.code);
// This is duplicative, but that's because TS can't distinguish the state type easily. // This is duplicative, but that's because TS can't distinguish the state type easily.
if (state.type === EMOJI_TYPE_UNICODE) { if (state.type === EMOJI_TYPE_UNICODE || legacyCode) {
const data = await loadEmojiByHexcode(state.code, locale); const data = await loadEmojiByHexcode(
legacyCode?.hexcode ?? state.code,
locale,
);
if (data) { if (data) {
return { return {
...state, ...state,
type: EMOJI_TYPE_UNICODE,
data, data,
// TODO: Use CLDR shortcodes when the picker supports them.
shortcode: legacyCode?.shortcodes.at(0),
}; };
} }
} else { } else {

View File

@ -4,6 +4,7 @@ import type { FlatCompactEmoji, Locale } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type { CustomEmoji } from '@/mastodon/models/custom_emoji'; import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
import type { RequiredExcept } from '@/mastodon/utils/types';
import type { import type {
EMOJI_MODE_NATIVE, EMOJI_MODE_NATIVE,
@ -40,6 +41,7 @@ export interface EmojiStateUnicode {
type: typeof EMOJI_TYPE_UNICODE; type: typeof EMOJI_TYPE_UNICODE;
code: string; code: string;
data?: UnicodeEmojiData; data?: UnicodeEmojiData;
shortcode?: string;
} }
export interface EmojiStateCustom { export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM; type: typeof EMOJI_TYPE_CUSTOM;
@ -49,7 +51,7 @@ export interface EmojiStateCustom {
export type EmojiState = EmojiStateUnicode | EmojiStateCustom; export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiLoadedState = export type EmojiLoadedState =
| Required<EmojiStateUnicode> | RequiredExcept<EmojiStateUnicode, 'shortcode'>
| Required<EmojiStateCustom>; | Required<EmojiStateCustom>;
export type CustomEmojiMapArg = export type CustomEmojiMapArg =

View File

@ -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); addEventListener('message', handleMessage);
self.postMessage('ready'); // After the worker is ready, notify the main thread 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) { async function loadData(locale: string, path?: string) {
let importCount: number | undefined; let importCount: number | undefined;
if (locale === 'custom') { if (locale === EMOJI_TYPE_CUSTOM) {
importCount = (await importCustomEmojiData())?.length; importCount = (await importCustomEmojiData())?.length;
} else if (locale === EMOJI_DB_NAME_SHORTCODES) {
importCount = (await importLegacyShortcodes()).length;
} else if (path) { } else if (path) {
importCount = (await importEmojiData(locale, path))?.length; importCount = (await importEmojiData(locale, path))?.length;
} else { } else {

View File

@ -55,9 +55,8 @@ function main() {
'Notification' in window && 'Notification' in window &&
Notification.permission === 'granted' Notification.permission === 'granted'
) { ) {
const registerPushNotifications = await import( const registerPushNotifications =
'mastodon/actions/push_notifications' await import('mastodon/actions/push_notifications');
);
store.dispatch(registerPushNotifications.register()); store.dispatch(registerPushNotifications.register());
} }

View File

@ -42,10 +42,9 @@ const AccountRoleFactory = ImmutableRecord<AccountRoleShape>({
}); });
// Account // Account
export interface AccountShape export interface AccountShape extends Required<
extends Required< Omit<ApiAccountJSON, 'emojis' | 'fields' | 'roles' | 'moved' | 'url'>
Omit<ApiAccountJSON, 'emojis' | 'fields' | 'roles' | 'moved' | 'url'> > {
> {
emojis: ImmutableList<CustomEmoji>; emojis: ImmutableList<CustomEmoji>;
fields: ImmutableList<AccountField>; fields: ImmutableList<AccountField>;
roles: ImmutableList<AccountRole>; roles: ImmutableList<AccountRole>;

View File

@ -14,20 +14,24 @@ import type { ApiReportJSON } from 'mastodon/api_types/reports';
// This corresponds to the max length of `group.sampleAccountIds` // This corresponds to the max length of `group.sampleAccountIds`
export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8; export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
interface BaseNotificationGroup interface BaseNotificationGroup extends Omit<
extends Omit<BaseNotificationGroupJSON, 'sample_account_ids'> { BaseNotificationGroupJSON,
'sample_account_ids'
> {
sampleAccountIds: string[]; sampleAccountIds: string[];
partial: boolean; partial: boolean;
} }
interface BaseNotificationWithStatus<Type extends NotificationWithStatusType> interface BaseNotificationWithStatus<
extends BaseNotificationGroup { Type extends NotificationWithStatusType,
> extends BaseNotificationGroup {
type: Type; type: Type;
statusId: string | undefined; statusId: string | undefined;
} }
interface BaseNotification<Type extends NotificationType> interface BaseNotification<
extends BaseNotificationGroup { Type extends NotificationType,
> extends BaseNotificationGroup {
type: Type; type: Type;
} }
@ -53,26 +57,25 @@ export type AccountWarningAction =
| 'sensitive' | 'sensitive'
| 'silence' | 'silence'
| 'suspend'; | 'suspend';
export interface AccountWarning export interface AccountWarning extends Omit<
extends Omit<ApiAccountWarningJSON, 'target_account'> { ApiAccountWarningJSON,
'target_account'
> {
targetAccountId: string; targetAccountId: string;
} }
export interface NotificationGroupModerationWarning export interface NotificationGroupModerationWarning extends BaseNotification<'moderation_warning'> {
extends BaseNotification<'moderation_warning'> {
moderationWarning: AccountWarning; moderationWarning: AccountWarning;
} }
type AccountRelationshipSeveranceEvent = type AccountRelationshipSeveranceEvent =
ApiAccountRelationshipSeveranceEventJSON; ApiAccountRelationshipSeveranceEventJSON;
export interface NotificationGroupSeveredRelationships export interface NotificationGroupSeveredRelationships extends BaseNotification<'severed_relationships'> {
extends BaseNotification<'severed_relationships'> {
event: AccountRelationshipSeveranceEvent; event: AccountRelationshipSeveranceEvent;
} }
type AnnualReportEvent = ApiAnnualReportEventJSON; type AnnualReportEvent = ApiAnnualReportEventJSON;
export interface NotificationGroupAnnualReport export interface NotificationGroupAnnualReport extends BaseNotification<'annual_report'> {
extends BaseNotification<'annual_report'> {
annualReport: AnnualReportEvent; annualReport: AnnualReportEvent;
} }
@ -80,8 +83,7 @@ interface Report extends Omit<ApiReportJSON, 'target_account'> {
targetAccountId: string; targetAccountId: string;
} }
export interface NotificationGroupAdminReport export interface NotificationGroupAdminReport extends BaseNotification<'admin.report'> {
extends BaseNotification<'admin.report'> {
report: Report; report: Report;
} }

View File

@ -1,7 +1,9 @@
import type { ApiNotificationRequestJSON } from 'mastodon/api_types/notifications'; import type { ApiNotificationRequestJSON } from 'mastodon/api_types/notifications';
export interface NotificationRequest export interface NotificationRequest extends Omit<
extends Omit<ApiNotificationRequestJSON, 'account' | 'notifications_count'> { ApiNotificationRequestJSON,
'account' | 'notifications_count'
> {
account_id: string; account_id: string;
notifications_count: number; notifications_count: number;
} }

View File

@ -25,8 +25,10 @@ export function createPollOptionTranslationFromServerJSON(translation: {
} as PollOptionTranslation; } as PollOptionTranslation;
} }
export interface Poll export interface Poll extends Omit<
extends Omit<ApiPollJSON, 'emojis' | 'options' | 'own_votes'> { ApiPollJSON,
'emojis' | 'options' | 'own_votes'
> {
emojis: CustomEmoji[]; emojis: CustomEmoji[];
options: PollOption[]; options: PollOption[];
own_votes?: number[]; own_votes?: number[];

View File

@ -62,7 +62,8 @@ export const checkAnnualReport = createAppThunk(
`${annualReportSlice.name}/checkAnnualReport`, `${annualReportSlice.name}/checkAnnualReport`,
(_arg: unknown, { dispatch, getState }) => { (_arg: unknown, { dispatch, getState }) => {
const year = selectWrapstodonYear(getState()); const year = selectWrapstodonYear(getState());
if (!year) { const me = getState().meta.get('me') as string;
if (!year || !me) {
return; return;
} }
void dispatch(fetchReportState()); void dispatch(fetchReportState());

View File

@ -15,6 +15,8 @@ export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> & export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Partial<Pick<T, K>>; Partial<Pick<T, K>>;
export type RequiredExcept<T, K extends keyof T> = SomeOptional<Required<T>, K>;
export type OmitValueType<T, V> = { export type OmitValueType<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K]; [K in keyof T as T[K] extends V ? never : K]: T[K];
}; };

View File

@ -628,11 +628,6 @@ body > [data-popper-placement] {
gap: 8px; gap: 8px;
margin: 8px; margin: 8px;
flex-wrap: wrap; flex-wrap: wrap;
& > div {
overflow: hidden;
display: flex;
}
} }
&__uploads { &__uploads {
@ -772,7 +767,6 @@ body > [data-popper-placement] {
align-items: center; align-items: center;
flex: 1 1 auto; flex: 1 1 auto;
max-width: 100%; max-width: 100%;
overflow: hidden;
} }
&__buttons { &__buttons {
@ -932,6 +926,11 @@ body > [data-popper-placement] {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&[disabled] { &[disabled] {
cursor: default; cursor: default;
color: var(--color-text-disabled); color: var(--color-text-disabled);

View File

@ -118,6 +118,7 @@ export function unicodeEmojiFactory(
hexcode: 'test', hexcode: 'test',
label: 'Test', label: 'Test',
unicode: '🧪', unicode: '🧪',
shortcodes: ['test_emoji'],
...data, ...data,
}; };
} }

View File

@ -275,7 +275,7 @@ class Request
end end
if ::HTTP::Response.methods.include?(:body_with_limit) && !Rails.env.production? 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 else
class ::HTTP::Response class ::HTTP::Response
include Request::ClientLimit include Request::ClientLimit

View File

@ -3,10 +3,9 @@
class StatusFilter class StatusFilter
attr_reader :status, :account attr_reader :status, :account
def initialize(status, account, preloaded_relations = {}) def initialize(status, account)
@status = status @status = status
@account = account @account = account
@preloaded_relations = preloaded_relations
end end
def filtered? def filtered?
@ -40,15 +39,15 @@ class StatusFilter
end end
def blocking_account? def blocking_account?
@preloaded_relations[:blocking] ? @preloaded_relations[:blocking][status.account_id] : account.blocking?(status.account_id) account.blocking?(status.account_id)
end end
def blocking_domain? 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 end
def muting_account? def muting_account?
@preloaded_relations[:muting] ? @preloaded_relations[:muting][status.account_id] : account.muting?(status.account_id) account.muting?(status.account_id)
end end
def silenced_account? def silenced_account?
@ -60,7 +59,7 @@ class StatusFilter
end end
def account_following_status_account? 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 end
def blocked_by_policy? def blocked_by_policy?
@ -68,6 +67,6 @@ class StatusFilter
end end
def policy_allows_show? def policy_allows_show?
StatusPolicy.new(account, status, @preloaded_relations).show? StatusPolicy.new(account, status).show?
end end
end end

View File

@ -123,7 +123,11 @@ module Account::Interactions
end end
def following?(other_account) 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 end
def following_anyone? def following_anyone?
@ -139,15 +143,33 @@ module Account::Interactions
end end
def blocking?(other_account) 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 end
def domain_blocking?(other_domain) 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 end
def muting?(other_account) 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 end
def muting_conversation?(conversation) def muting_conversation?(conversation)
@ -226,4 +248,10 @@ module Account::Interactions
def normalized_domain(domain) def normalized_domain(domain)
TagManager.instance.normalize_domain(domain) TagManager.instance.normalize_domain(domain)
end end
private
def preloaded_relation(type, key)
@preloaded_relations && @preloaded_relations[type] ? @preloaded_relations[type][key].present? : yield
end
end end

View File

@ -91,6 +91,12 @@ module Account::Mappings
end end
end end
def preload_relations!(...)
@preloaded_relations = relations_map(...)
end
private
def relations_map(account_ids, domains = nil, **options) def relations_map(account_ids, domains = nil, **options)
relations = { relations = {
blocked_by: Account.blocked_by_map(account_ids, id), blocked_by: Account.blocked_by_map(account_ids, id),

View File

@ -26,7 +26,7 @@ module Status::InteractionPolicyConcern
end end
# Returns `:automatic`, `:manual`, `:unknown` or `:denied` # 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? return :denied if other_account.nil? || direct_visibility? || reblog?
following_author = nil following_author = nil
@ -41,7 +41,7 @@ module Status::InteractionPolicyConcern
return :automatic if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public]) return :automatic if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public])
if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers]) 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 return :automatic if following_author
end end
@ -54,7 +54,7 @@ module Status::InteractionPolicyConcern
return :manual if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public]) return :manual if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public])
if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers]) 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 return :manual if following_author
end end

View File

@ -8,9 +8,10 @@ module Status::ThreadingConcern
statuses = Status.with_accounts(ids).to_a statuses = Status.with_accounts(ids).to_a
account_ids = statuses.map(&:account_id).uniq account_ids = statuses.map(&:account_id).uniq
domains = statuses.filter_map(&:account_domain).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 if stable
statuses.sort_by! { |status| ids.index(status.id) } statuses.sort_by! { |status| ids.index(status.id) }

View File

@ -66,6 +66,6 @@ class AccountPolicy < ApplicationPolicy
end end
def feature? 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
end end

View File

@ -1,12 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Admin::StatusPolicy < ApplicationPolicy class Admin::StatusPolicy < ApplicationPolicy
def initialize(current_account, record, preloaded_relations = {})
super(current_account, record)
@preloaded_relations = preloaded_relations
end
def index? def index?
role.can?(:manage_reports, :manage_users) role.can?(:manage_reports, :manage_users)
end end
@ -34,6 +28,6 @@ class Admin::StatusPolicy < ApplicationPolicy
end end
def viewable_through_normal_policy? def viewable_through_normal_policy?
StatusPolicy.new(current_account, record, @preloaded_relations).show? StatusPolicy.new(current_account, record).show?
end end
end end

View File

@ -1,12 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class StatusPolicy < ApplicationPolicy class StatusPolicy < ApplicationPolicy
def initialize(current_account, record, preloaded_relations = {})
super(current_account, record)
@preloaded_relations = preloaded_relations
end
def show? def show?
return false if author.unavailable? return false if author.unavailable?
return false if local_only? && (current_account.nil? || !current_account.local?) 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 # This is about requesting a quote post, not validating it
def quote? 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 end
def reblog? def reblog?
@ -76,19 +70,19 @@ class StatusPolicy < ApplicationPolicy
def blocking_author? def blocking_author?
return false if current_account.nil? return false if current_account.nil?
@preloaded_relations[:blocking] ? @preloaded_relations[:blocking][author.id] : current_account.blocking?(author) current_account.blocking?(author)
end end
def author_blocking? def author_blocking?
return false if current_account.nil? 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 end
def following_author? def following_author?
return false if current_account.nil? return false if current_account.nil?
@preloaded_relations[:following] ? @preloaded_relations[:following][author.id] : current_account.following?(author) current_account.following?(author)
end end
def author def author

View File

@ -41,14 +41,8 @@ class StatusRelationshipsPresenter
end end
# This one is currently on-demand as it is only used for quote posts # This one is currently on-demand as it is only used for quote posts
def preloaded_account_relations def authoring_accounts
@preloaded_account_relations ||= begin @authoring_accounts ||= @statuses.compact.flat_map { |s| [s.account, s.proper.account, s.proper.quote&.quoted_account] }.uniq.compact
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
end end
private private

View File

@ -19,6 +19,13 @@ class REST::BaseQuoteSerializer < ActiveModel::Serializer
private private
def status_filter 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
end end

View File

@ -2,9 +2,10 @@
class CreateCollectionService class CreateCollectionService
def call(params, account) 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 })) @collection = Collection.new(params.merge({ account:, local: true }))
build_items(account_ids) build_items
@collection.save! @collection.save!
@collection @collection
@ -12,13 +13,14 @@ class CreateCollectionService
private private
def build_items(account_ids) def build_items
return if account_ids.blank? return if @accounts_to_add.empty?
account_ids.each do |account_id| @account.preload_relations!(@accounts_to_add.map(&:id))
account = Account.find(account_id) @accounts_to_add.each do |account_to_add|
# TODO: validate preferences 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:)
@collection.collection_items.build(account: account_to_add)
end end
end end
end end

View File

@ -29,9 +29,10 @@ class StatusesSearchService < BaseService
results = request.collapse(field: :id).order(id: { order: :desc }).limit(@limit).offset(@offset).objects.compact results = request.collapse(field: :id).order(id: { order: :desc }).limit(@limit).offset(@offset).objects.compact
account_ids = results.map(&:account_id) account_ids = results.map(&:account_id)
account_domains = results.map(&:account_domain) 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 rescue Faraday::ConnectionFailed, Parslet::ParseFailed, Errno::ENETUNREACH
[] []
end end

View File

@ -11,7 +11,6 @@
= render 'og_description', account: @account = render 'og_description', account: @account
= render 'og_image', report: @generated_annual_report = render 'og_image', report: @generated_annual_report
= render_initial_state
= flavoured_vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous' = flavoured_vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous'
- content_for :html_classes, 'theme-dark' - content_for :html_classes, 'theme-dark'

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
unless ENV.key?('RAILS_ENV') unless ENV.key?('RAILS_ENV')
abort <<~ERROR abort <<~ERROR # rubocop:disable Rails/Exit
The RAILS_ENV environment variable is not set. The RAILS_ENV environment variable is not set.
Please set it correctly depending on context: Please set it correctly depending on context:

View File

@ -13,7 +13,7 @@
value = ENV.fetch(key, '') value = ENV.fetch(key, '')
if value.blank? if value.blank?
abort <<~MESSAGE abort <<~MESSAGE # rubocop:disable Rails/Exit
Mastodon now requires that these variables are set: 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') 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. It looks like you are trying to run Mastodon in production with a #{key} value from the test environment.

View File

@ -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')}". 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 MESSAGE
abort message abort message # rubocop:disable Rails/Exit
end end
if ENV['MASTODON_USE_LIBVIPS'] == 'false' if ENV['MASTODON_USE_LIBVIPS'] == 'false'

View File

@ -6,7 +6,7 @@ if Rails.configuration.x.use_vips
require 'vips' require 'vips'
unless Vips.at_least_libvips?(8, 13) 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 Incompatible libvips version (#{Vips.version_string}), please install libvips >= 8.13
ERROR ERROR
end end

View File

@ -13,7 +13,7 @@ namespace :api, format: false do
resources :async_refreshes, only: :show resources :async_refreshes, only: :show
resources :collections, only: [:show, :create, :update, :destroy] do 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
end end

View File

@ -15,9 +15,8 @@ export function MastodonEmojiCompressed(): Plugin {
}, },
async load(id) { async load(id) {
if (id === resolvedVirtualModuleId) { if (id === resolvedVirtualModuleId) {
const { default: emojiCompressed } = await import( const { default: emojiCompressed } =
'../../app/javascript/mastodon/features/emoji/emoji_compressed.mjs' await import('../../app/javascript/mastodon/features/emoji/emoji_compressed.mjs');
);
return `export default ${JSON.stringify(emojiCompressed)};`; return `export default ${JSON.stringify(emojiCompressed)};`;
} }

View File

@ -115,7 +115,7 @@
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"stringz": "^2.1.0", "stringz": "^2.1.0",
"substring-trie": "^1.0.2", "substring-trie": "^1.0.2",
"tesseract.js": "^6.0.0", "tesseract.js": "^7.0.0",
"tiny-queue": "^0.2.1", "tiny-queue": "^0.2.1",
"twitter-text": "3.1.0", "twitter-text": "3.1.0",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.0",

View File

@ -302,9 +302,24 @@ RSpec.describe Account::Interactions do
subject { account.following?(target_account) } subject { account.following?(target_account) }
context 'when following target_account' do context 'when following target_account' do
it 'returns true' do before do
account.active_relationships.create(target_account: target_account) 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
end end
@ -336,9 +351,26 @@ RSpec.describe Account::Interactions do
subject { account.blocking?(target_account) } subject { account.blocking?(target_account) }
context 'when blocking target_account' do context 'when blocking target_account' do
it 'returns true' do before do
account.block_relationships.create(target_account: target_account) 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
end end
@ -349,16 +381,65 @@ RSpec.describe Account::Interactions do
end end
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 describe '#domain_blocking?' do
subject { account.domain_blocking?(domain) } subject { account.domain_blocking?(domain) }
let(:domain) { 'example.com' } let(:domain) { 'example.com' }
context 'when blocking the domain' do context 'when blocking the domain' do
it 'returns true' do before do
account_domain_block = Fabricate(:account_domain_block, domain: domain) account_domain_block = Fabricate(:account_domain_block, domain: domain)
account.domain_blocks << account_domain_block 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
end end
@ -373,10 +454,25 @@ RSpec.describe Account::Interactions do
subject { account.muting?(target_account) } subject { account.muting?(target_account) }
context 'when muting target_account' do context 'when muting target_account' do
it 'returns true' do before do
mute = Fabricate(:mute, account: account, target_account: target_account) mute = Fabricate(:mute, account: account, target_account: target_account)
account.mute_relationships << mute 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
end end

View File

@ -52,4 +52,53 @@ RSpec.describe 'Api::V1Alpha::CollectionItems', feature: :collections do
end end
end 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 end

View File

@ -30,9 +30,10 @@ RSpec.describe CreateCollectionService do
end end
context 'when given account ids' do context 'when given account ids' do
let(:account_ids) do let(:accounts) do
Fabricate.times(2, :account).map { |a| a.id.to_s } Fabricate.times(2, :account)
end end
let(:account_ids) { accounts.map { |a| a.id.to_s } }
let(:params) do let(:params) do
base_params.merge(account_ids:) base_params.merge(account_ids:)
end end
@ -42,6 +43,18 @@ RSpec.describe CreateCollectionService do
subject.call(params, author) subject.call(params, author)
end.to change(CollectionItem, :count).by(2) end.to change(CollectionItem, :count).by(2)
end 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 end
context 'when given a tag' do context 'when given a tag' do

View File

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

249
yarn.lock
View File

@ -1306,6 +1306,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@csstools/css-tokenizer@npm:^3.0.4":
version: 3.0.4 version: 3.0.4
resolution: "@csstools/css-tokenizer@npm: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-prettier-scss: "npm:^1.0.0"
stylelint-config-standard-scss: "npm:^16.0.0" stylelint-config-standard-scss: "npm:^16.0.0"
substring-trie: "npm:^1.0.2" substring-trie: "npm:^1.0.2"
tesseract.js: "npm:^6.0.0" tesseract.js: "npm:^7.0.0"
tiny-queue: "npm:^0.2.1" tiny-queue: "npm:^0.2.1"
twitter-text: "npm:3.1.0" twitter-text: "npm:3.1.0"
typescript: "npm:~5.9.0" typescript: "npm:~5.9.0"
@ -4846,28 +4853,28 @@ __metadata:
linkType: hard linkType: hard
"@vitest/browser-playwright@npm:^4.0.5": "@vitest/browser-playwright@npm:^4.0.5":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/browser-playwright@npm:4.0.13" resolution: "@vitest/browser-playwright@npm:4.0.15"
dependencies: dependencies:
"@vitest/browser": "npm:4.0.13" "@vitest/browser": "npm:4.0.15"
"@vitest/mocker": "npm:4.0.13" "@vitest/mocker": "npm:4.0.15"
tinyrainbow: "npm:^3.0.3" tinyrainbow: "npm:^3.0.3"
peerDependencies: peerDependencies:
playwright: "*" playwright: "*"
vitest: 4.0.13 vitest: 4.0.15
peerDependenciesMeta: peerDependenciesMeta:
playwright: playwright:
optional: false optional: false
checksum: 10c0/5a387eb02534736a25cfff442e66e8c41ef97f0db744ffe8360e484af61d66db793cb44ba8681471b8c21ba509db1775f1ba688bc7f50325eee76918773848cb checksum: 10c0/ce357cc96b5d391fa701d545089475feac64e6febdce0d95a75e7c9c29ad35650372e6930a492750af2a4633f4f9354463968f435713da4f035befeb4e3ecf84
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/browser@npm:4.0.13, @vitest/browser@npm:^4.0.5": "@vitest/browser@npm:4.0.15, @vitest/browser@npm:^4.0.5":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/browser@npm:4.0.13" resolution: "@vitest/browser@npm:4.0.15"
dependencies: dependencies:
"@vitest/mocker": "npm:4.0.13" "@vitest/mocker": "npm:4.0.15"
"@vitest/utils": "npm:4.0.13" "@vitest/utils": "npm:4.0.15"
magic-string: "npm:^0.30.21" magic-string: "npm:^0.30.21"
pixelmatch: "npm:7.1.0" pixelmatch: "npm:7.1.0"
pngjs: "npm:^7.0.0" pngjs: "npm:^7.0.0"
@ -4875,33 +4882,33 @@ __metadata:
tinyrainbow: "npm:^3.0.3" tinyrainbow: "npm:^3.0.3"
ws: "npm:^8.18.3" ws: "npm:^8.18.3"
peerDependencies: peerDependencies:
vitest: 4.0.13 vitest: 4.0.15
checksum: 10c0/22c9297888a7288717cad706ca08159b3af05337a2f9b8da98fe74e683d534c8d816e40fece96f218d223a54c06762c5aa2a5db23ce8565c174ab9a70aade7f0 checksum: 10c0/b74c1ab5b03a494b1a91e270417a794e616d3d9d5002de816b6a9913073fdf5939ca63b30a37e4e865cb9402b8682254facaf4b854d002b65b6ea85fccf38253
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/coverage-v8@npm:^4.0.5": "@vitest/coverage-v8@npm:^4.0.5":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/coverage-v8@npm:4.0.13" resolution: "@vitest/coverage-v8@npm:4.0.15"
dependencies: dependencies:
"@bcoe/v8-coverage": "npm:^1.0.2" "@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" ast-v8-to-istanbul: "npm:^0.3.8"
debug: "npm:^4.4.3"
istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-coverage: "npm:^3.2.2"
istanbul-lib-report: "npm:^3.0.1" istanbul-lib-report: "npm:^3.0.1"
istanbul-lib-source-maps: "npm:^5.0.6" istanbul-lib-source-maps: "npm:^5.0.6"
istanbul-reports: "npm:^3.2.0" istanbul-reports: "npm:^3.2.0"
magicast: "npm:^0.5.1" magicast: "npm:^0.5.1"
obug: "npm:^2.1.1"
std-env: "npm:^3.10.0" std-env: "npm:^3.10.0"
tinyrainbow: "npm:^3.0.3" tinyrainbow: "npm:^3.0.3"
peerDependencies: peerDependencies:
"@vitest/browser": 4.0.13 "@vitest/browser": 4.0.15
vitest: 4.0.13 vitest: 4.0.15
peerDependenciesMeta: peerDependenciesMeta:
"@vitest/browser": "@vitest/browser":
optional: true optional: true
checksum: 10c0/dd462b13605fca62d20cb5a4f9d7cfda2bfa5e77aedc16ad5a633d8dabb24f68e96382ac4d16c2fdcddb45e7c4717e558f5ac51a70c64857f5e89d12d8700823 checksum: 10c0/8810cb35fc443bdd5da46ea90804d7657d17ceb20dc9f7e05c7f6212480039c6079ec4ff0c305a658044b7cbd8792a71c7ae6661258fc5f3022e04bea04186a0
languageName: node languageName: node
linkType: hard linkType: hard
@ -4918,17 +4925,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/expect@npm:4.0.13": "@vitest/expect@npm:4.0.15":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/expect@npm:4.0.13" resolution: "@vitest/expect@npm:4.0.15"
dependencies: dependencies:
"@standard-schema/spec": "npm:^1.0.0" "@standard-schema/spec": "npm:^1.0.0"
"@types/chai": "npm:^5.2.2" "@types/chai": "npm:^5.2.2"
"@vitest/spy": "npm:4.0.13" "@vitest/spy": "npm:4.0.15"
"@vitest/utils": "npm:4.0.13" "@vitest/utils": "npm:4.0.15"
chai: "npm:^6.2.1" chai: "npm:^6.2.1"
tinyrainbow: "npm:^3.0.3" tinyrainbow: "npm:^3.0.3"
checksum: 10c0/1cd7dc02cb650d024826f2e20260d23c2b9ab6733341045ffb59be7af73402eecd2422198d7e4eac609710730b6d11f0faf22af0c074d65445ab88d9da7f6556 checksum: 10c0/0cb98a4918ca84b28cd14120bb66c1bc3084f8f95b649066cdab2f5234ecdbe247cdc6bc47c0d939521d964ff3c150aadd9558272495c26872c9f3a97373bf7b
languageName: node languageName: node
linkType: hard linkType: hard
@ -4951,11 +4958,11 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/mocker@npm:4.0.13": "@vitest/mocker@npm:4.0.15":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/mocker@npm:4.0.13" resolution: "@vitest/mocker@npm:4.0.15"
dependencies: dependencies:
"@vitest/spy": "npm:4.0.13" "@vitest/spy": "npm:4.0.15"
estree-walker: "npm:^3.0.3" estree-walker: "npm:^3.0.3"
magic-string: "npm:^0.30.21" magic-string: "npm:^0.30.21"
peerDependencies: peerDependencies:
@ -4966,7 +4973,7 @@ __metadata:
optional: true optional: true
vite: vite:
optional: true optional: true
checksum: 10c0/667ec4fbb77a28ede1b055b9d962beed92c2dd2d83b7bab1ed22239578a7b399180a978e26ef136301c0bc7c57c75ca178cda55ec94081856437e3b4be4a3e19 checksum: 10c0/7a164aa25daab3e92013ec95aab5c5778e805b1513e266ce6c00e0647eb9f7b281e33fcaf0d9d2aed88321079183b60c1aeab90961f618c24e2e3a5143308850
languageName: node languageName: node
linkType: hard linkType: hard
@ -4979,33 +4986,33 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/pretty-format@npm:4.0.13": "@vitest/pretty-format@npm:4.0.15":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/pretty-format@npm:4.0.13" resolution: "@vitest/pretty-format@npm:4.0.15"
dependencies: dependencies:
tinyrainbow: "npm:^3.0.3" tinyrainbow: "npm:^3.0.3"
checksum: 10c0/c32ebd3457fd4b92fa89800b0ddaa2ca7de84df75be3c64f87ace006f3a3ec546a6ffd4c06f88e3161e80f9e10c83dfee61150e682eaa5a1871240d98c7ef0eb checksum: 10c0/d863f3818627b198f9c66515f8faa200e76a1c30c7f2b25ac805e253204ae51abbfa6b6211c58b2d75e1a273a2e6925e3a8fa480ebfa9c479d75a19815e1cbea
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/runner@npm:4.0.13": "@vitest/runner@npm:4.0.15":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/runner@npm:4.0.13" resolution: "@vitest/runner@npm:4.0.15"
dependencies: dependencies:
"@vitest/utils": "npm:4.0.13" "@vitest/utils": "npm:4.0.15"
pathe: "npm:^2.0.3" pathe: "npm:^2.0.3"
checksum: 10c0/e9f95b8a413f875123e5c32322dd92bd523d6e3ba25b054f0e865f42e01f82666b847535fe5ea2ff3238faa2df16cefc7e5845d3d5ccfecb3a96ab924d31e760 checksum: 10c0/0b0f23b8fed1a98bb85d71a7fc105726e0fae68667b090c40b636011126fef548a5f853eab40aaf47314913ab6480eefe2aa5bd6bcefc4116e034fdc1ac0f7d0
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/snapshot@npm:4.0.13": "@vitest/snapshot@npm:4.0.15":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/snapshot@npm:4.0.13" resolution: "@vitest/snapshot@npm:4.0.15"
dependencies: dependencies:
"@vitest/pretty-format": "npm:4.0.13" "@vitest/pretty-format": "npm:4.0.15"
magic-string: "npm:^0.30.21" magic-string: "npm:^0.30.21"
pathe: "npm:^2.0.3" pathe: "npm:^2.0.3"
checksum: 10c0/ad3fbe9ff30bc294811556f958e0014cb03888ea06ac7c05ab41e20c582fe8e27d8f4176aaf8a8e230fc6377124af30f5622173fb459b70a30ff9dd622664be2 checksum: 10c0/f05a19f74512cbad9bcfe4afe814c676b72b7e54ccf09c5b36e06e73614a24fdba47bdb8a94279162b7fdf83c9c840f557073a114a9339df7e75ccb9f4e99218
languageName: node languageName: node
linkType: hard linkType: hard
@ -5018,18 +5025,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/spy@npm:4.0.13": "@vitest/spy@npm:4.0.15":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/spy@npm:4.0.13" resolution: "@vitest/spy@npm:4.0.15"
checksum: 10c0/64dc4c496eb9aacd3137beedccdb3265c895f8cd2626b3f76d7324ad944be5b1567ede2652eee407991796879270a63abdec4453c73185e637a1d7ff9cd1a009 checksum: 10c0/22319cead44964882d9e7bd197a9cd317c945ff75a4a9baefe06c32c5719d4cb887e86b4560d79716765f288a93a6c76e78e3eeab0000f24b2236dab678b7c34
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/ui@npm:^4.0.5": "@vitest/ui@npm:^4.0.5":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/ui@npm:4.0.13" resolution: "@vitest/ui@npm:4.0.15"
dependencies: dependencies:
"@vitest/utils": "npm:4.0.13" "@vitest/utils": "npm:4.0.15"
fflate: "npm:^0.8.2" fflate: "npm:^0.8.2"
flatted: "npm:^3.3.3" flatted: "npm:^3.3.3"
pathe: "npm:^2.0.3" pathe: "npm:^2.0.3"
@ -5037,8 +5044,8 @@ __metadata:
tinyglobby: "npm:^0.2.15" tinyglobby: "npm:^0.2.15"
tinyrainbow: "npm:^3.0.3" tinyrainbow: "npm:^3.0.3"
peerDependencies: peerDependencies:
vitest: 4.0.13 vitest: 4.0.15
checksum: 10c0/7656762bc6a9c99850639d0809ada53ad4b842e4d9a8c7b82987b60bcf1675c98c077516a3777fce9580255538d0d050c92cb1e6f6296af6365f2387d7a972b9 checksum: 10c0/f6d1729de4d0ab43fbcc48aa1935315ab1c039fcf216abc01222e9170e0cb1829300a950952ca025ee9af0618fb4c24199cb1a912a76466c87a0eee86ed91d11
languageName: node languageName: node
linkType: hard linkType: hard
@ -5053,13 +5060,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/utils@npm:4.0.13": "@vitest/utils@npm:4.0.15":
version: 4.0.13 version: 4.0.15
resolution: "@vitest/utils@npm:4.0.13" resolution: "@vitest/utils@npm:4.0.15"
dependencies: dependencies:
"@vitest/pretty-format": "npm:4.0.13" "@vitest/pretty-format": "npm:4.0.15"
tinyrainbow: "npm:^3.0.3" tinyrainbow: "npm:^3.0.3"
checksum: 10c0/1b64872e82a652f11bfd813c0140eaae9b6e4ece39fc0e460ab2b3111b925892f1128f3b27f3a280471cfc404bb9c9289c59f8ca5387950ab35d024d154e9ec1 checksum: 10c0/2ef661c2c2359ae956087f0b728b6a0f7555cd10524a7def27909f320f6b8ba00560ee1bd856bf68d4debc01020cea21b200203a5d2af36c44e94528c5587aee
languageName: node languageName: node
linkType: hard linkType: hard
@ -7432,10 +7439,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"fast-copy@npm:^3.0.2": "fast-copy@npm:^4.0.0":
version: 3.0.2 version: 4.0.1
resolution: "fast-copy@npm:3.0.2" resolution: "fast-copy@npm:4.0.1"
checksum: 10c0/02e8b9fd03c8c024d2987760ce126456a0e17470850b51e11a1c3254eed6832e4733ded2d93316c82bc0b36aeb991ad1ff48d1ba95effe7add7c3ab8d8eb554a checksum: 10c0/5c0ccddc68cb8f071d7806681f4bb87741cc07ed3153bba49adc456607c6d38854bf921c318feba402442056f7babcdc435052f431f19b2c7c6cbeefe9ae5841
languageName: node languageName: node
linkType: hard linkType: hard
@ -7529,7 +7536,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"file-entry-cache@npm:^11.1.0": "file-entry-cache@npm:^11.1.1":
version: 11.1.1 version: 11.1.1
resolution: "file-entry-cache@npm:11.1.1" resolution: "file-entry-cache@npm:11.1.1"
dependencies: dependencies:
@ -10050,6 +10057,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "on-exit-leak-free@npm:^2.1.0":
version: 2.1.2 version: 2.1.2
resolution: "on-exit-leak-free@npm:2.1.2" resolution: "on-exit-leak-free@npm:2.1.2"
@ -10461,6 +10475,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "pino-http@npm:^11.0.0":
version: 11.0.0 version: 11.0.0
resolution: "pino-http@npm:11.0.0" resolution: "pino-http@npm:11.0.0"
@ -10474,25 +10497,25 @@ __metadata:
linkType: hard linkType: hard
"pino-pretty@npm:^13.0.0": "pino-pretty@npm:^13.0.0":
version: 13.1.2 version: 13.1.3
resolution: "pino-pretty@npm:13.1.2" resolution: "pino-pretty@npm:13.1.3"
dependencies: dependencies:
colorette: "npm:^2.0.7" colorette: "npm:^2.0.7"
dateformat: "npm:^4.6.3" dateformat: "npm:^4.6.3"
fast-copy: "npm:^3.0.2" fast-copy: "npm:^4.0.0"
fast-safe-stringify: "npm:^2.1.1" fast-safe-stringify: "npm:^2.1.1"
help-me: "npm:^5.0.0" help-me: "npm:^5.0.0"
joycon: "npm:^3.1.1" joycon: "npm:^3.1.1"
minimist: "npm:^1.2.6" minimist: "npm:^1.2.6"
on-exit-leak-free: "npm:^2.1.0" 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" pump: "npm:^3.0.0"
secure-json-parse: "npm:^4.0.0" secure-json-parse: "npm:^4.0.0"
sonic-boom: "npm:^4.0.1" sonic-boom: "npm:^4.0.1"
strip-json-comments: "npm:^5.0.2" strip-json-comments: "npm:^5.0.2"
bin: bin:
pino-pretty: bin.js pino-pretty: bin.js
checksum: 10c0/4d8e7472e37bdb6e0d6d7d34f25f65ced46c0f64a9579bb805602321caf1c0b10359f89a1ee9742bea875f411a02ce7c19730f7a1e5387dfcfd10ff5c9804709 checksum: 10c0/36fa382521a893290c8f6a5b2ddc28dfb87fda1d161adb6b97d80bf7d24184970d0a7eab6f8ee45c39aff4b2ec3b2e533c756899798adc270010f34ba4411063
languageName: node languageName: node
linkType: hard linkType: hard
@ -11091,11 +11114,11 @@ __metadata:
linkType: hard linkType: hard
"prettier@npm:^3.3.3": "prettier@npm:^3.3.3":
version: 3.6.2 version: 3.7.4
resolution: "prettier@npm:3.6.2" resolution: "prettier@npm:3.7.4"
bin: bin:
prettier: bin/prettier.cjs prettier: bin/prettier.cjs
checksum: 10c0/488cb2f2b99ec13da1e50074912870217c11edaddedeadc649b1244c749d15ba94e846423d062e2c4c9ae683e2d65f754de28889ba06e697ac4f988d44f45812 checksum: 10c0/9675d2cd08eacb1faf1d1a2dbfe24bfab6a912b059fc9defdb380a408893d88213e794a40a2700bd29b140eb3172e0b07c852853f6e22f16f3374659a1a13389
languageName: node languageName: node
linkType: hard linkType: hard
@ -12186,8 +12209,8 @@ __metadata:
linkType: hard linkType: hard
"sass@npm:^1.62.1, sass@npm:^1.70.0": "sass@npm:^1.62.1, sass@npm:^1.70.0":
version: 1.94.2 version: 1.96.0
resolution: "sass@npm:1.94.2" resolution: "sass@npm:1.96.0"
dependencies: dependencies:
"@parcel/watcher": "npm:^2.4.1" "@parcel/watcher": "npm:^2.4.1"
chokidar: "npm:^4.0.0" chokidar: "npm:^4.0.0"
@ -12198,7 +12221,7 @@ __metadata:
optional: true optional: true
bin: bin:
sass: sass.js sass: sass.js
checksum: 10c0/49a656dfab58299165ef94e71483a333972daee68c49fa542858d4912accdfb1707338226a165b1a2dfcdb2509fcda5a5b4f3780d14e49b6d38d93c8043475d3 checksum: 10c0/a932054bcee6935757417af6072d31b65ce3557798a53351b3e1369d7f06e24b0ec211e1617bdaaee998b429a44bf0f52acd240fd47f88422d5bc241eeb71672
languageName: node languageName: node
linkType: hard linkType: hard
@ -13069,10 +13092,11 @@ __metadata:
linkType: hard linkType: hard
"stylelint@npm:^16.19.1": "stylelint@npm:^16.19.1":
version: 16.26.0 version: 16.26.1
resolution: "stylelint@npm:16.26.0" resolution: "stylelint@npm:16.26.1"
dependencies: dependencies:
"@csstools/css-parser-algorithms": "npm:^3.0.5" "@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/css-tokenizer": "npm:^3.0.4"
"@csstools/media-query-list-parser": "npm:^4.0.3" "@csstools/media-query-list-parser": "npm:^4.0.3"
"@csstools/selector-specificity": "npm:^5.0.0" "@csstools/selector-specificity": "npm:^5.0.0"
@ -13085,7 +13109,7 @@ __metadata:
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
fast-glob: "npm:^3.3.3" fast-glob: "npm:^3.3.3"
fastest-levenshtein: "npm:^1.0.16" 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" global-modules: "npm:^2.0.0"
globby: "npm:^11.1.0" globby: "npm:^11.1.0"
globjoin: "npm:^0.1.4" globjoin: "npm:^0.1.4"
@ -13112,7 +13136,7 @@ __metadata:
write-file-atomic: "npm:^5.0.1" write-file-atomic: "npm:^5.0.1"
bin: bin:
stylelint: bin/stylelint.mjs stylelint: bin/stylelint.mjs
checksum: 10c0/6f501ff051aee4fc7713635c98bf6837f889b22fe86152cfed20365ffeee0acf9d751f94ff265433b532b2a1ab7a228fc1fda3f507859acb57a689268939553d checksum: 10c0/3805dfe868abdcc5a62e5726eebe5e950432cfadfc5b47c2f103ef4dede8ee1eb8a1247c9ceb01a1739c0aba68865d79899d33a707256365bb2004664524908b
languageName: node languageName: node
linkType: hard linkType: hard
@ -13268,16 +13292,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tesseract.js-core@npm:^6.0.0": "tesseract.js-core@npm:^7.0.0":
version: 6.0.0 version: 7.0.0
resolution: "tesseract.js-core@npm:6.0.0" resolution: "tesseract.js-core@npm:7.0.0"
checksum: 10c0/c04be8bbaa296be658664496754f21e857bdffff84113f08adf02f03a1f84596d68b3542ed2fda4a6dc138abb84b09b30ab07c04ee5950879e780876d343955f checksum: 10c0/c1afee9f8aecf994bc4714fd879e57d04b995849345532872bdc3d8c82a59c4ebbb0acde14d2b24e6a3aec27cafee3d18931f2744496d603ae36241290108e17
languageName: node languageName: node
linkType: hard linkType: hard
"tesseract.js@npm:^6.0.0": "tesseract.js@npm:^7.0.0":
version: 6.0.1 version: 7.0.0
resolution: "tesseract.js@npm:6.0.1" resolution: "tesseract.js@npm:7.0.0"
dependencies: dependencies:
bmp-js: "npm:^0.1.0" bmp-js: "npm:^0.1.0"
idb-keyval: "npm:^6.2.0" idb-keyval: "npm:^6.2.0"
@ -13285,10 +13309,10 @@ __metadata:
node-fetch: "npm:^2.6.9" node-fetch: "npm:^2.6.9"
opencollective-postinstall: "npm:^2.0.3" opencollective-postinstall: "npm:^2.0.3"
regenerator-runtime: "npm:^0.13.3" regenerator-runtime: "npm:^0.13.3"
tesseract.js-core: "npm:^6.0.0" tesseract.js-core: "npm:^7.0.0"
wasm-feature-detect: "npm:^1.2.11" wasm-feature-detect: "npm:^1.8.0"
zlibjs: "npm:^0.3.1" zlibjs: "npm:^0.3.1"
checksum: 10c0/1d73bb1fbc00c8629756d9594989d8bbfabda657a8cad84922ad68eb0f073148c82845bf71a882e5d2427a46edb5a470356864e60562c7a8442bddd70251435a checksum: 10c0/daf5b153a9a06e0ab3365b33f4cc323e375d3a8b86a7df98031c19047623451aa3bfb293c295b0ebecd5c0781e42e43469d93ed4e7ba8a868b7a457120e609a1
languageName: node languageName: node
linkType: hard linkType: hard
@ -13329,10 +13353,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tinyexec@npm:^0.3.2": "tinyexec@npm:^1.0.2":
version: 0.3.2 version: 1.0.2
resolution: "tinyexec@npm:0.3.2" resolution: "tinyexec@npm:1.0.2"
checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 checksum: 10c0/1261a8e34c9b539a9aae3b7f0bb5372045ff28ee1eba035a2a059e532198fe1a182ec61ac60fa0b4a4129f0c4c4b1d2d57355b5cb9aa2d17ac9454ecace502ee
languageName: node languageName: node
linkType: hard linkType: hard
@ -14132,25 +14156,25 @@ __metadata:
linkType: hard linkType: hard
"vitest@npm:^4.0.5": "vitest@npm:^4.0.5":
version: 4.0.13 version: 4.0.15
resolution: "vitest@npm:4.0.13" resolution: "vitest@npm:4.0.15"
dependencies: dependencies:
"@vitest/expect": "npm:4.0.13" "@vitest/expect": "npm:4.0.15"
"@vitest/mocker": "npm:4.0.13" "@vitest/mocker": "npm:4.0.15"
"@vitest/pretty-format": "npm:4.0.13" "@vitest/pretty-format": "npm:4.0.15"
"@vitest/runner": "npm:4.0.13" "@vitest/runner": "npm:4.0.15"
"@vitest/snapshot": "npm:4.0.13" "@vitest/snapshot": "npm:4.0.15"
"@vitest/spy": "npm:4.0.13" "@vitest/spy": "npm:4.0.15"
"@vitest/utils": "npm:4.0.13" "@vitest/utils": "npm:4.0.15"
debug: "npm:^4.4.3"
es-module-lexer: "npm:^1.7.0" es-module-lexer: "npm:^1.7.0"
expect-type: "npm:^1.2.2" expect-type: "npm:^1.2.2"
magic-string: "npm:^0.30.21" magic-string: "npm:^0.30.21"
obug: "npm:^2.1.1"
pathe: "npm:^2.0.3" pathe: "npm:^2.0.3"
picomatch: "npm:^4.0.3" picomatch: "npm:^4.0.3"
std-env: "npm:^3.10.0" std-env: "npm:^3.10.0"
tinybench: "npm:^2.9.0" tinybench: "npm:^2.9.0"
tinyexec: "npm:^0.3.2" tinyexec: "npm:^1.0.2"
tinyglobby: "npm:^0.2.15" tinyglobby: "npm:^0.2.15"
tinyrainbow: "npm:^3.0.3" tinyrainbow: "npm:^3.0.3"
vite: "npm:^6.0.0 || ^7.0.0" vite: "npm:^6.0.0 || ^7.0.0"
@ -14158,12 +14182,11 @@ __metadata:
peerDependencies: peerDependencies:
"@edge-runtime/vm": "*" "@edge-runtime/vm": "*"
"@opentelemetry/api": ^1.9.0 "@opentelemetry/api": ^1.9.0
"@types/debug": ^4.1.12
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
"@vitest/browser-playwright": 4.0.13 "@vitest/browser-playwright": 4.0.15
"@vitest/browser-preview": 4.0.13 "@vitest/browser-preview": 4.0.15
"@vitest/browser-webdriverio": 4.0.13 "@vitest/browser-webdriverio": 4.0.15
"@vitest/ui": 4.0.13 "@vitest/ui": 4.0.15
happy-dom: "*" happy-dom: "*"
jsdom: "*" jsdom: "*"
peerDependenciesMeta: peerDependenciesMeta:
@ -14171,8 +14194,6 @@ __metadata:
optional: true optional: true
"@opentelemetry/api": "@opentelemetry/api":
optional: true optional: true
"@types/debug":
optional: true
"@types/node": "@types/node":
optional: true optional: true
"@vitest/browser-playwright": "@vitest/browser-playwright":
@ -14189,7 +14210,7 @@ __metadata:
optional: true optional: true
bin: bin:
vitest: vitest.mjs vitest: vitest.mjs
checksum: 10c0/8582ab1848d5d7dbbac0b3a5eae2625f44d0db887f73da2ee8f588fb13c66fe8ea26dac05c26ebb43673b735bc246764f52969f7c7e25455dfb7c6274659ae2c checksum: 10c0/fd57913dbcba81b67ca67bae37f0920f2785a60939a9029a82ebb843253f7a67f93f2c959cb90bb23a57055c0256ec0a6059ec9a10c129e8912c09b6e407242b
languageName: node languageName: node
linkType: hard linkType: hard
@ -14211,7 +14232,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"wasm-feature-detect@npm:^1.2.11": "wasm-feature-detect@npm:^1.8.0":
version: 1.8.0 version: 1.8.0
resolution: "wasm-feature-detect@npm:1.8.0" resolution: "wasm-feature-detect@npm:1.8.0"
checksum: 10c0/2cb43e91bbf7aa7c121bc76b3133de3ab6dc4f482acc1d2dc46c528e8adb7a51c72df5c2aacf1d219f113c04efd1706f18274d5790542aa5dd49e0644e3ee665 checksum: 10c0/2cb43e91bbf7aa7c121bc76b3133de3ab6dc4f482acc1d2dc46c528e8adb7a51c72df5c2aacf1d219f113c04efd1706f18274d5790542aa5dd49e0644e3ee665