Merge pull request #3315 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to cbe135210305c0ce4455130c4e75680e53ff1425
This commit is contained in:
Claire 2025-12-15 22:18:41 +01:00 committed by GitHub
commit 57554e9e33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
94 changed files with 1459 additions and 487 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 { action } from 'storybook/actions';
import {
importCustomEmojiData,
importLegacyShortcodes,
importEmojiData,
} from '@/mastodon/features/emoji/loader';
import type { LocaleData } from '@/mastodon/locales';
import { reducerWithInitialState } from '@/mastodon/reducers';
import { defaultMiddleware } from '@/mastodon/store/store';
@ -127,7 +132,12 @@ const preview: Preview = {
</MemoryRouter>
),
],
loaders: [mswLoader],
loaders: [
mswLoader,
importCustomEmojiData,
importLegacyShortcodes,
({ globals: { locale } }) => importEmojiData(locale as string),
],
parameters: {
layout: 'centered',

View File

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

View File

@ -39,7 +39,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
return @body if defined?(@body)
@body = request.body.read
@body.force_encoding('UTF-8') if @body.present?
@body.presence&.force_encoding('UTF-8')
request.body.rewind if request.body.respond_to?(:rewind)

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
module DatabaseHelper
def replica_enabled?
ENV['REPLICA_DB_NAME'] || ENV.fetch('REPLICA_DATABASE_URL', nil)
ENV['REPLICA_DB_NAME'] || ENV['REPLICA_DB_HOST'] || ENV.fetch('REPLICA_DATABASE_URL', nil)
end
module_function :replica_enabled?

View File

@ -2,15 +2,19 @@
module WrapstodonHelper
def render_wrapstodon_share_data(report)
json = ActiveModelSerializers::SerializableResource.new(
payload = ActiveModelSerializers::SerializableResource.new(
AnnualReportsPresenter.new([report]),
serializer: REST::AnnualReportsSerializer,
scope: nil,
scope_name: :current_user
).to_json
).as_json
payload[:me] = current_account.id.to_s if user_signed_in?
json_string = payload.to_json
# rubocop:disable Rails/OutputSafety
content_tag(:script, json_escape(json).html_safe, type: 'application/json', id: 'wrapstodon-data')
content_tag(:script, json_escape(json_string).html_safe, type: 'application/json', id: 'wrapstodon-data')
# rubocop:enable Rails/OutputSafety
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,8 +2,6 @@ import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { importCustomEmojiData } from '@/flavours/glitch/features/emoji/loader';
import { Emoji } from './index';
type EmojiProps = ComponentProps<typeof Emoji> & { state: string };
@ -38,7 +36,6 @@ const meta = {
},
},
render(args) {
void importCustomEmojiData();
return <Emoji {...args} />;
},
} satisfies Meta<EmojiProps>;
@ -54,3 +51,9 @@ export const CustomEmoji: Story = {
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 { EMOJI_TYPE_CUSTOM } from '@/flavours/glitch/features/emoji/constants';
import {
EMOJI_TYPE_CUSTOM,
EMOJI_TYPE_UNICODE,
} from '@/flavours/glitch/features/emoji/constants';
import { useEmojiAppState } from '@/flavours/glitch/features/emoji/mode';
import {
emojiToInversionClassName,
@ -47,8 +50,6 @@ export const Emoji: FC<EmojiProps> = ({
const animate = useContext(AnimateEmojiContext);
const inversionClass = emojiToInversionClassName(code);
const fallback = showFallback ? code : null;
// If the code is invalid or we otherwise know it's not valid, show the fallback.
@ -56,10 +57,6 @@ export const Emoji: FC<EmojiProps> = ({
return fallback;
}
if (!shouldRenderImage(state, appState.mode)) {
return code;
}
if (!isStateLoaded(state)) {
if (showLoading) {
return <span className='emojione emoji-loading' title={code} />;
@ -67,6 +64,17 @@ export const Emoji: FC<EmojiProps> = ({
return fallback;
}
const inversionClass =
state.type === EMOJI_TYPE_UNICODE &&
emojiToInversionClassName(state.data.unicode);
if (!shouldRenderImage(state, appState.mode)) {
if (state.type === EMOJI_TYPE_UNICODE) {
return state.data.unicode;
}
return code;
}
if (state.type === EMOJI_TYPE_CUSTOM) {
const shortcode = `:${state.code}:`;
return (

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Avatar } from '@/flavours/glitch/components/avatar';
import { Button } from '@/flavours/glitch/components/button';
import { DisplayName } from '@/flavours/glitch/components/display_name';
import { me } from '@/flavours/glitch/initial_state';
import type { Account } from '@/flavours/glitch/models/account';
import type {
@ -137,9 +138,6 @@ export const Archetype: React.FC<{
? archetypeSelfDescriptions
: archetypePublicDescriptions;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we specifically want to fallback if `display_name` is empty
const name = account?.display_name || account?.username;
return (
<div
className={classNames(styles.box, styles.archetype)}
@ -182,7 +180,9 @@ export const Archetype: React.FC<{
<FormattedMessage
id='annual_report.summary.archetype.title_public'
defaultMessage="{name}'s archetype"
values={{ name }}
values={{
name: <DisplayName variant='simple' account={account} />,
}}
/>
)}
</h2>
@ -199,7 +199,7 @@ export const Archetype: React.FC<{
<p>
{isRevealed ? (
intl.formatMessage(descriptions[archetype], {
name,
name: <DisplayName variant='simple' account={account} />,
})
) : (
<FormattedMessage

View File

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

View File

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

View File

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

View File

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

View File

@ -23,6 +23,10 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji';
export const EMOJI_TYPE_UNICODE = 'unicode';
export const EMOJI_TYPE_CUSTOM = 'custom';
export const EMOJI_DB_NAME_SHORTCODES = 'shortcodes';
export const EMOJI_DB_SHORTCODE_TEST = '2122'; // 2122 is the trademark sign, which we know has shortcodes in all datasets.
export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1
'🐜', // 1F41C

View File

@ -1,7 +1,8 @@
import { IDBFactory } from 'fake-indexeddb';
import { unicodeEmojiFactory } from '@/testing/factories';
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import {
putEmojiData,
loadEmojiByHexcode,
@ -9,6 +10,11 @@ import {
searchEmojisByTag,
testClear,
testGet,
putCustomEmojiData,
putLegacyShortcodes,
loadLegacyShortcodesByShortcode,
loadLatestEtag,
putLatestEtag,
} from './database';
describe('emoji database', () => {
@ -16,6 +22,7 @@ describe('emoji database', () => {
testClear();
indexedDB = new IDBFactory();
});
describe('putEmojiData', () => {
test('adds to loaded locales', async () => {
const { loadedLocales } = await testGet();
@ -33,6 +40,29 @@ describe('emoji database', () => {
});
});
describe('putCustomEmojiData', () => {
test('loads custom emoji into indexedDB', async () => {
const { db } = await testGet();
await putCustomEmojiData([customEmojiFactory()]);
await expect(db.get('custom', 'custom')).resolves.toEqual(
customEmojiFactory(),
);
});
});
describe('putLegacyShortcodes', () => {
test('loads shortcodes into indexedDB', async () => {
const { db } = await testGet();
await putLegacyShortcodes({
test_hexcode: ['shortcode1', 'shortcode2'],
});
await expect(db.get('shortcodes', 'test_hexcode')).resolves.toEqual({
hexcode: 'test_hexcode',
shortcodes: ['shortcode1', 'shortcode2'],
});
});
});
describe('loadEmojiByHexcode', () => {
test('throws if the locale is not loaded', async () => {
await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError(
@ -136,4 +166,58 @@ describe('emoji database', () => {
expect(actual).toHaveLength(0);
});
});
describe('loadLegacyShortcodesByShortcode', () => {
const data = {
hexcode: 'test_hexcode',
shortcodes: ['shortcode1', 'shortcode2'],
};
beforeEach(async () => {
await putLegacyShortcodes({
[data.hexcode]: data.shortcodes,
});
});
test('retrieves the shortcodes', async () => {
await expect(
loadLegacyShortcodesByShortcode('shortcode1'),
).resolves.toEqual(data);
await expect(
loadLegacyShortcodesByShortcode('shortcode2'),
).resolves.toEqual(data);
});
});
describe('loadLatestEtag', () => {
beforeEach(async () => {
await putLatestEtag('etag', 'en');
await putEmojiData([unicodeEmojiFactory()], 'en');
await putLatestEtag('fr-etag', 'fr');
});
test('retrieves the etag for loaded locale', async () => {
await putEmojiData(
[unicodeEmojiFactory({ hexcode: EMOJI_DB_SHORTCODE_TEST })],
'en',
);
const etag = await loadLatestEtag('en');
expect(etag).toBe('etag');
});
test('returns null if locale has no shortcodes', async () => {
const etag = await loadLatestEtag('en');
expect(etag).toBeNull();
});
test('returns null if locale not loaded', async () => {
const etag = await loadLatestEtag('de');
expect(etag).toBeNull();
});
test('returns null if locale has no data', async () => {
const etag = await loadLatestEtag('fr');
expect(etag).toBeNull();
});
});
});

View File

@ -1,8 +1,9 @@
import { SUPPORTED_LOCALES } from 'emojibase';
import type { Locale } from 'emojibase';
import type { Locale, ShortcodesDataset } from 'emojibase';
import type { DBSchema, IDBPDatabase } from 'idb';
import { openDB } from 'idb';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type {
CustomEmojiData,
@ -19,6 +20,17 @@ interface EmojiDB extends LocaleTables, DBSchema {
category: string;
};
};
shortcodes: {
key: string;
value: {
hexcode: string;
shortcodes: string[];
};
indexes: {
hexcode: string;
shortcodes: string[];
};
};
etags: {
key: LocaleOrCustom;
value: string;
@ -33,13 +45,14 @@ interface LocaleTable {
label: string;
order: number;
tags: string[];
shortcodes: string[];
};
}
type LocaleTables = Record<Locale, LocaleTable>;
type Database = IDBPDatabase<EmojiDB>;
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
const loadedLocales = new Set<Locale>();
@ -52,28 +65,76 @@ const loadDB = (() => {
// Actually load the DB.
async function initDB() {
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
customTable.createIndex('category', 'category');
upgrade(database, oldVersion, newVersion, trx) {
if (!database.objectStoreNames.contains('custom')) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
customTable.createIndex('category', 'category');
}
database.createObjectStore('etags');
if (!database.objectStoreNames.contains('etags')) {
database.createObjectStore('etags');
}
for (const locale of SUPPORTED_LOCALES) {
const localeTable = database.createObjectStore(locale, {
if (!database.objectStoreNames.contains(locale)) {
const localeTable = database.createObjectStore(locale, {
keyPath: 'hexcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
localeTable.createIndex('shortcodes', 'shortcodes', {
multiEntry: true,
});
}
// Added in version 2.
const localeTable = trx.objectStore(locale);
if (!localeTable.indexNames.contains('shortcodes')) {
localeTable.createIndex('shortcodes', 'shortcodes', {
multiEntry: true,
});
}
}
if (!database.objectStoreNames.contains('shortcodes')) {
const shortcodeTable = database.createObjectStore('shortcodes', {
keyPath: 'hexcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
shortcodeTable.createIndex('hexcode', 'hexcode');
shortcodeTable.createIndex('shortcodes', 'shortcodes', {
multiEntry: true,
});
}
log(
'Upgraded emoji database from version %d to %d',
oldVersion,
newVersion,
);
},
blocked(currentVersion, blockedVersion) {
log(
'Emoji database upgrade from version %d to %d is blocked',
currentVersion,
blockedVersion,
);
},
blocking(currentVersion, blockedVersion) {
log(
'Emoji database upgrade from version %d is blocking upgrade to %d',
currentVersion,
blockedVersion,
);
},
});
await syncLocales(db);
log('Loaded database version %d', db.version);
return db;
}
@ -107,6 +168,20 @@ export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
await trx.done;
}
export async function putLegacyShortcodes(shortcodes: ShortcodesDataset) {
const db = await loadDB();
const trx = db.transaction('shortcodes', 'readwrite');
await Promise.all(
Object.entries(shortcodes).map(([hexcode, codes]) =>
trx.store.put({
hexcode,
shortcodes: Array.isArray(codes) ? codes : [codes],
}),
),
);
await trx.done;
}
export async function putLatestEtag(etag: string, localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
@ -161,6 +236,15 @@ export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
}
export async function loadLegacyShortcodesByShortcode(shortcode: string) {
const db = await loadDB();
return db.getFromIndex(
'shortcodes',
'shortcodes',
IDBKeyRange.only(shortcode),
);
}
export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
@ -168,6 +252,15 @@ export async function loadLatestEtag(localeString: string) {
if (!rowCount) {
return null; // No data for this locale, return null even if there is an etag.
}
// Check if shortcodes exist for the given Unicode locale.
if (locale !== 'custom') {
const result = await db.get(locale, EMOJI_DB_SHORTCODE_TEST);
if (!result?.shortcodes) {
return null;
}
}
const etag = await db.get('etags', locale);
return etag ?? null;
}

View File

@ -1,5 +1,9 @@
import type { Locale } from 'emojibase';
import { initialState } from '@/flavours/glitch/initial_state';
import type { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
import { importLegacyShortcodes, localeToShortcodesPath } from './loader';
import { toSupportedLocale } from './locale';
import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
@ -36,12 +40,8 @@ export function initializeEmoji() {
log('worker ready, loading data');
clearTimeout(timeoutId);
messageWorker('custom');
messageWorker('shortcodes');
void loadEmojiLocale(userLocale);
// Load English locale as well, because people are still used to
// using it from before we supported other locales.
if (userLocale !== 'en') {
void loadEmojiLocale('en');
}
} else {
log('got worker message: %s', message);
}
@ -58,20 +58,23 @@ async function fallbackLoad() {
if (emojis) {
log('loaded %d custom emojis', emojis.length);
}
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
const shortcodes = await importLegacyShortcodes();
if (shortcodes.length) {
log('loaded %d legacy shortcodes', shortcodes.length);
}
await loadEmojiLocale(userLocale);
}
async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
const { importEmojiData, localeToPath } = await import('./loader');
const { importEmojiData, localeToEmojiPath: localeToPath } =
await import('./loader');
if (worker) {
const path = await localeToPath(locale);
const shortcodesPath = await localeToShortcodesPath(locale);
log('asking worker to load locale %s from %s', locale, path);
messageWorker(locale, path);
messageWorker(locale, path, shortcodesPath);
} else {
const emojis = await importEmojiData(locale);
if (emojis) {
@ -80,9 +83,17 @@ async function loadEmojiLocale(localeString: string) {
}
}
function messageWorker(locale: LocaleOrCustom, path?: string) {
function messageWorker(
locale: typeof EMOJI_TYPE_CUSTOM | typeof EMOJI_DB_NAME_SHORTCODES,
): void;
function messageWorker(locale: Locale, path: string, shortcodes?: string): void;
function messageWorker(
locale: LocaleOrCustom | typeof EMOJI_DB_NAME_SHORTCODES,
path?: string,
shortcodes?: string,
) {
if (!worker) {
return;
}
worker.postMessage({ locale, path });
worker.postMessage({ locale, path, shortcodes });
}

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import type { FlatCompactEmoji, Locale } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/flavours/glitch/api_types/custom_emoji';
import type { CustomEmoji } from '@/flavours/glitch/models/custom_emoji';
import type { RequiredExcept } from '@/flavours/glitch/utils/types';
import type {
EMOJI_MODE_NATIVE,
@ -40,6 +41,7 @@ export interface EmojiStateUnicode {
type: typeof EMOJI_TYPE_UNICODE;
code: string;
data?: UnicodeEmojiData;
shortcode?: string;
}
export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM;
@ -49,7 +51,7 @@ export interface EmojiStateCustom {
export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiLoadedState =
| Required<EmojiStateUnicode>
| RequiredExcept<EmojiStateUnicode, 'shortcode'>
| Required<EmojiStateCustom>;
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);
self.postMessage('ready'); // After the worker is ready, notify the main thread
@ -12,8 +17,10 @@ function handleMessage(event: MessageEvent<{ locale: string; path?: string }>) {
async function loadData(locale: string, path?: string) {
let importCount: number | undefined;
if (locale === 'custom') {
if (locale === EMOJI_TYPE_CUSTOM) {
importCount = (await importCustomEmojiData())?.length;
} else if (locale === EMOJI_DB_NAME_SHORTCODES) {
importCount = (await importLegacyShortcodes()).length;
} else if (path) {
importCount = (await importEmojiData(locale, path))?.length;
} else {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -628,11 +628,6 @@ body > [data-popper-placement] {
gap: 8px;
margin: 8px;
flex-wrap: wrap;
& > div {
overflow: hidden;
display: flex;
}
}
&__uploads {
@ -813,7 +808,6 @@ body > [data-popper-placement] {
display: flex;
flex: 1 0 100%; // glitch: always on its own line
max-width: 100%;
overflow: hidden;
gap: 5px; // glitch: handle secondary post privacy
align-items: stretch; // glitch: handle secondary post privacy
@ -985,6 +979,11 @@ body > [data-popper-placement] {
text-overflow: ellipsis;
white-space: nowrap;
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&[disabled] {
cursor: default;
color: var(--color-text-disabled);

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>> &
Partial<Pick<T, K>>;
export type RequiredExcept<T, K extends keyof T> = SomeOptional<Required<T>, K>;
export type OmitValueType<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K];
};

View File

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

View File

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

View File

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

View File

@ -2,8 +2,6 @@ import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { importCustomEmojiData } from '@/mastodon/features/emoji/loader';
import { Emoji } from './index';
type EmojiProps = ComponentProps<typeof Emoji> & { state: string };
@ -38,7 +36,6 @@ const meta = {
},
},
render(args) {
void importCustomEmojiData();
return <Emoji {...args} />;
},
} satisfies Meta<EmojiProps>;
@ -54,3 +51,9 @@ export const CustomEmoji: Story = {
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 { EMOJI_TYPE_CUSTOM } from '@/mastodon/features/emoji/constants';
import {
EMOJI_TYPE_CUSTOM,
EMOJI_TYPE_UNICODE,
} from '@/mastodon/features/emoji/constants';
import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
import {
emojiToInversionClassName,
@ -47,8 +50,6 @@ export const Emoji: FC<EmojiProps> = ({
const animate = useContext(AnimateEmojiContext);
const inversionClass = emojiToInversionClassName(code);
const fallback = showFallback ? code : null;
// If the code is invalid or we otherwise know it's not valid, show the fallback.
@ -56,10 +57,6 @@ export const Emoji: FC<EmojiProps> = ({
return fallback;
}
if (!shouldRenderImage(state, appState.mode)) {
return code;
}
if (!isStateLoaded(state)) {
if (showLoading) {
return <span className='emojione emoji-loading' title={code} />;
@ -67,6 +64,17 @@ export const Emoji: FC<EmojiProps> = ({
return fallback;
}
const inversionClass =
state.type === EMOJI_TYPE_UNICODE &&
emojiToInversionClassName(state.data.unicode);
if (!shouldRenderImage(state, appState.mode)) {
if (state.type === EMOJI_TYPE_UNICODE) {
return state.data.unicode;
}
return code;
}
if (state.type === EMOJI_TYPE_CUSTOM) {
const shortcode = `:${state.code}:`;
return (

View File

@ -12,6 +12,7 @@ import replier from '@/images/archetypes/replier.png';
import space_elements from '@/images/archetypes/space_elements.png';
import { Avatar } from '@/mastodon/components/avatar';
import { Button } from '@/mastodon/components/button';
import { DisplayName } from '@/mastodon/components/display_name';
import { me } from '@/mastodon/initial_state';
import type { Account } from '@/mastodon/models/account';
import type {
@ -137,9 +138,6 @@ export const Archetype: React.FC<{
? archetypeSelfDescriptions
: archetypePublicDescriptions;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we specifically want to fallback if `display_name` is empty
const name = account?.display_name || account?.username;
return (
<div
className={classNames(styles.box, styles.archetype)}
@ -182,7 +180,9 @@ export const Archetype: React.FC<{
<FormattedMessage
id='annual_report.summary.archetype.title_public'
defaultMessage="{name}'s archetype"
values={{ name }}
values={{
name: <DisplayName variant='simple' account={account} />,
}}
/>
)}
</h2>
@ -199,7 +199,7 @@ export const Archetype: React.FC<{
<p>
{isRevealed ? (
intl.formatMessage(descriptions[archetype], {
name,
name: <DisplayName variant='simple' account={account} />,
})
) : (
<FormattedMessage

View File

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

View File

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

View File

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

View File

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

View File

@ -23,6 +23,10 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji';
export const EMOJI_TYPE_UNICODE = 'unicode';
export const EMOJI_TYPE_CUSTOM = 'custom';
export const EMOJI_DB_NAME_SHORTCODES = 'shortcodes';
export const EMOJI_DB_SHORTCODE_TEST = '2122'; // 2122 is the trademark sign, which we know has shortcodes in all datasets.
export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1
'🐜', // 1F41C

View File

@ -1,7 +1,8 @@
import { IDBFactory } from 'fake-indexeddb';
import { unicodeEmojiFactory } from '@/testing/factories';
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import {
putEmojiData,
loadEmojiByHexcode,
@ -9,6 +10,11 @@ import {
searchEmojisByTag,
testClear,
testGet,
putCustomEmojiData,
putLegacyShortcodes,
loadLegacyShortcodesByShortcode,
loadLatestEtag,
putLatestEtag,
} from './database';
describe('emoji database', () => {
@ -16,6 +22,7 @@ describe('emoji database', () => {
testClear();
indexedDB = new IDBFactory();
});
describe('putEmojiData', () => {
test('adds to loaded locales', async () => {
const { loadedLocales } = await testGet();
@ -33,6 +40,29 @@ describe('emoji database', () => {
});
});
describe('putCustomEmojiData', () => {
test('loads custom emoji into indexedDB', async () => {
const { db } = await testGet();
await putCustomEmojiData([customEmojiFactory()]);
await expect(db.get('custom', 'custom')).resolves.toEqual(
customEmojiFactory(),
);
});
});
describe('putLegacyShortcodes', () => {
test('loads shortcodes into indexedDB', async () => {
const { db } = await testGet();
await putLegacyShortcodes({
test_hexcode: ['shortcode1', 'shortcode2'],
});
await expect(db.get('shortcodes', 'test_hexcode')).resolves.toEqual({
hexcode: 'test_hexcode',
shortcodes: ['shortcode1', 'shortcode2'],
});
});
});
describe('loadEmojiByHexcode', () => {
test('throws if the locale is not loaded', async () => {
await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError(
@ -136,4 +166,58 @@ describe('emoji database', () => {
expect(actual).toHaveLength(0);
});
});
describe('loadLegacyShortcodesByShortcode', () => {
const data = {
hexcode: 'test_hexcode',
shortcodes: ['shortcode1', 'shortcode2'],
};
beforeEach(async () => {
await putLegacyShortcodes({
[data.hexcode]: data.shortcodes,
});
});
test('retrieves the shortcodes', async () => {
await expect(
loadLegacyShortcodesByShortcode('shortcode1'),
).resolves.toEqual(data);
await expect(
loadLegacyShortcodesByShortcode('shortcode2'),
).resolves.toEqual(data);
});
});
describe('loadLatestEtag', () => {
beforeEach(async () => {
await putLatestEtag('etag', 'en');
await putEmojiData([unicodeEmojiFactory()], 'en');
await putLatestEtag('fr-etag', 'fr');
});
test('retrieves the etag for loaded locale', async () => {
await putEmojiData(
[unicodeEmojiFactory({ hexcode: EMOJI_DB_SHORTCODE_TEST })],
'en',
);
const etag = await loadLatestEtag('en');
expect(etag).toBe('etag');
});
test('returns null if locale has no shortcodes', async () => {
const etag = await loadLatestEtag('en');
expect(etag).toBeNull();
});
test('returns null if locale not loaded', async () => {
const etag = await loadLatestEtag('de');
expect(etag).toBeNull();
});
test('returns null if locale has no data', async () => {
const etag = await loadLatestEtag('fr');
expect(etag).toBeNull();
});
});
});

View File

@ -1,8 +1,9 @@
import { SUPPORTED_LOCALES } from 'emojibase';
import type { Locale } from 'emojibase';
import type { Locale, ShortcodesDataset } from 'emojibase';
import type { DBSchema, IDBPDatabase } from 'idb';
import { openDB } from 'idb';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type {
CustomEmojiData,
@ -19,6 +20,17 @@ interface EmojiDB extends LocaleTables, DBSchema {
category: string;
};
};
shortcodes: {
key: string;
value: {
hexcode: string;
shortcodes: string[];
};
indexes: {
hexcode: string;
shortcodes: string[];
};
};
etags: {
key: LocaleOrCustom;
value: string;
@ -33,13 +45,14 @@ interface LocaleTable {
label: string;
order: number;
tags: string[];
shortcodes: string[];
};
}
type LocaleTables = Record<Locale, LocaleTable>;
type Database = IDBPDatabase<EmojiDB>;
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
const loadedLocales = new Set<Locale>();
@ -52,28 +65,76 @@ const loadDB = (() => {
// Actually load the DB.
async function initDB() {
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
customTable.createIndex('category', 'category');
upgrade(database, oldVersion, newVersion, trx) {
if (!database.objectStoreNames.contains('custom')) {
const customTable = database.createObjectStore('custom', {
keyPath: 'shortcode',
autoIncrement: false,
});
customTable.createIndex('category', 'category');
}
database.createObjectStore('etags');
if (!database.objectStoreNames.contains('etags')) {
database.createObjectStore('etags');
}
for (const locale of SUPPORTED_LOCALES) {
const localeTable = database.createObjectStore(locale, {
if (!database.objectStoreNames.contains(locale)) {
const localeTable = database.createObjectStore(locale, {
keyPath: 'hexcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
localeTable.createIndex('shortcodes', 'shortcodes', {
multiEntry: true,
});
}
// Added in version 2.
const localeTable = trx.objectStore(locale);
if (!localeTable.indexNames.contains('shortcodes')) {
localeTable.createIndex('shortcodes', 'shortcodes', {
multiEntry: true,
});
}
}
if (!database.objectStoreNames.contains('shortcodes')) {
const shortcodeTable = database.createObjectStore('shortcodes', {
keyPath: 'hexcode',
autoIncrement: false,
});
localeTable.createIndex('group', 'group');
localeTable.createIndex('label', 'label');
localeTable.createIndex('order', 'order');
localeTable.createIndex('tags', 'tags', { multiEntry: true });
shortcodeTable.createIndex('hexcode', 'hexcode');
shortcodeTable.createIndex('shortcodes', 'shortcodes', {
multiEntry: true,
});
}
log(
'Upgraded emoji database from version %d to %d',
oldVersion,
newVersion,
);
},
blocked(currentVersion, blockedVersion) {
log(
'Emoji database upgrade from version %d to %d is blocked',
currentVersion,
blockedVersion,
);
},
blocking(currentVersion, blockedVersion) {
log(
'Emoji database upgrade from version %d is blocking upgrade to %d',
currentVersion,
blockedVersion,
);
},
});
await syncLocales(db);
log('Loaded database version %d', db.version);
return db;
}
@ -107,6 +168,20 @@ export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
await trx.done;
}
export async function putLegacyShortcodes(shortcodes: ShortcodesDataset) {
const db = await loadDB();
const trx = db.transaction('shortcodes', 'readwrite');
await Promise.all(
Object.entries(shortcodes).map(([hexcode, codes]) =>
trx.store.put({
hexcode,
shortcodes: Array.isArray(codes) ? codes : [codes],
}),
),
);
await trx.done;
}
export async function putLatestEtag(etag: string, localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
@ -161,6 +236,15 @@ export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
}
export async function loadLegacyShortcodesByShortcode(shortcode: string) {
const db = await loadDB();
return db.getFromIndex(
'shortcodes',
'shortcodes',
IDBKeyRange.only(shortcode),
);
}
export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
@ -168,6 +252,15 @@ export async function loadLatestEtag(localeString: string) {
if (!rowCount) {
return null; // No data for this locale, return null even if there is an etag.
}
// Check if shortcodes exist for the given Unicode locale.
if (locale !== 'custom') {
const result = await db.get(locale, EMOJI_DB_SHORTCODE_TEST);
if (!result?.shortcodes) {
return null;
}
}
const etag = await db.get('etags', locale);
return etag ?? null;
}

View File

@ -1,5 +1,9 @@
import type { Locale } from 'emojibase';
import { initialState } from '@/mastodon/initial_state';
import type { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
import { importLegacyShortcodes, localeToShortcodesPath } from './loader';
import { toSupportedLocale } from './locale';
import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
@ -36,12 +40,8 @@ export function initializeEmoji() {
log('worker ready, loading data');
clearTimeout(timeoutId);
messageWorker('custom');
messageWorker('shortcodes');
void loadEmojiLocale(userLocale);
// Load English locale as well, because people are still used to
// using it from before we supported other locales.
if (userLocale !== 'en') {
void loadEmojiLocale('en');
}
} else {
log('got worker message: %s', message);
}
@ -58,20 +58,23 @@ async function fallbackLoad() {
if (emojis) {
log('loaded %d custom emojis', emojis.length);
}
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
const shortcodes = await importLegacyShortcodes();
if (shortcodes.length) {
log('loaded %d legacy shortcodes', shortcodes.length);
}
await loadEmojiLocale(userLocale);
}
async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
const { importEmojiData, localeToPath } = await import('./loader');
const { importEmojiData, localeToEmojiPath: localeToPath } =
await import('./loader');
if (worker) {
const path = await localeToPath(locale);
const shortcodesPath = await localeToShortcodesPath(locale);
log('asking worker to load locale %s from %s', locale, path);
messageWorker(locale, path);
messageWorker(locale, path, shortcodesPath);
} else {
const emojis = await importEmojiData(locale);
if (emojis) {
@ -80,9 +83,17 @@ async function loadEmojiLocale(localeString: string) {
}
}
function messageWorker(locale: LocaleOrCustom, path?: string) {
function messageWorker(
locale: typeof EMOJI_TYPE_CUSTOM | typeof EMOJI_DB_NAME_SHORTCODES,
): void;
function messageWorker(locale: Locale, path: string, shortcodes?: string): void;
function messageWorker(
locale: LocaleOrCustom | typeof EMOJI_DB_NAME_SHORTCODES,
path?: string,
shortcodes?: string,
) {
if (!worker) {
return;
}
worker.postMessage({ locale, path });
worker.postMessage({ locale, path, shortcodes });
}

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import type { FlatCompactEmoji, Locale } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
import type { RequiredExcept } from '@/mastodon/utils/types';
import type {
EMOJI_MODE_NATIVE,
@ -40,6 +41,7 @@ export interface EmojiStateUnicode {
type: typeof EMOJI_TYPE_UNICODE;
code: string;
data?: UnicodeEmojiData;
shortcode?: string;
}
export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM;
@ -49,7 +51,7 @@ export interface EmojiStateCustom {
export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiLoadedState =
| Required<EmojiStateUnicode>
| RequiredExcept<EmojiStateUnicode, 'shortcode'>
| Required<EmojiStateCustom>;
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);
self.postMessage('ready'); // After the worker is ready, notify the main thread
@ -12,8 +17,10 @@ function handleMessage(event: MessageEvent<{ locale: string; path?: string }>) {
async function loadData(locale: string, path?: string) {
let importCount: number | undefined;
if (locale === 'custom') {
if (locale === EMOJI_TYPE_CUSTOM) {
importCount = (await importCustomEmojiData())?.length;
} else if (locale === EMOJI_DB_NAME_SHORTCODES) {
importCount = (await importLegacyShortcodes()).length;
} else if (path) {
importCount = (await importEmojiData(locale, path))?.length;
} else {

View File

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

View File

@ -42,10 +42,9 @@ const AccountRoleFactory = ImmutableRecord<AccountRoleShape>({
});
// Account
export interface AccountShape
extends Required<
Omit<ApiAccountJSON, 'emojis' | 'fields' | 'roles' | 'moved' | 'url'>
> {
export interface AccountShape extends Required<
Omit<ApiAccountJSON, 'emojis' | 'fields' | 'roles' | 'moved' | 'url'>
> {
emojis: ImmutableList<CustomEmoji>;
fields: ImmutableList<AccountField>;
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`
export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
interface BaseNotificationGroup
extends Omit<BaseNotificationGroupJSON, 'sample_account_ids'> {
interface BaseNotificationGroup extends Omit<
BaseNotificationGroupJSON,
'sample_account_ids'
> {
sampleAccountIds: string[];
partial: boolean;
}
interface BaseNotificationWithStatus<Type extends NotificationWithStatusType>
extends BaseNotificationGroup {
interface BaseNotificationWithStatus<
Type extends NotificationWithStatusType,
> extends BaseNotificationGroup {
type: Type;
statusId: string | undefined;
}
interface BaseNotification<Type extends NotificationType>
extends BaseNotificationGroup {
interface BaseNotification<
Type extends NotificationType,
> extends BaseNotificationGroup {
type: Type;
}
@ -53,26 +57,25 @@ export type AccountWarningAction =
| 'sensitive'
| 'silence'
| 'suspend';
export interface AccountWarning
extends Omit<ApiAccountWarningJSON, 'target_account'> {
export interface AccountWarning extends Omit<
ApiAccountWarningJSON,
'target_account'
> {
targetAccountId: string;
}
export interface NotificationGroupModerationWarning
extends BaseNotification<'moderation_warning'> {
export interface NotificationGroupModerationWarning extends BaseNotification<'moderation_warning'> {
moderationWarning: AccountWarning;
}
type AccountRelationshipSeveranceEvent =
ApiAccountRelationshipSeveranceEventJSON;
export interface NotificationGroupSeveredRelationships
extends BaseNotification<'severed_relationships'> {
export interface NotificationGroupSeveredRelationships extends BaseNotification<'severed_relationships'> {
event: AccountRelationshipSeveranceEvent;
}
type AnnualReportEvent = ApiAnnualReportEventJSON;
export interface NotificationGroupAnnualReport
extends BaseNotification<'annual_report'> {
export interface NotificationGroupAnnualReport extends BaseNotification<'annual_report'> {
annualReport: AnnualReportEvent;
}
@ -80,8 +83,7 @@ interface Report extends Omit<ApiReportJSON, 'target_account'> {
targetAccountId: string;
}
export interface NotificationGroupAdminReport
extends BaseNotification<'admin.report'> {
export interface NotificationGroupAdminReport extends BaseNotification<'admin.report'> {
report: Report;
}

View File

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

View File

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

View File

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

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>> &
Partial<Pick<T, K>>;
export type RequiredExcept<T, K extends keyof T> = SomeOptional<Required<T>, K>;
export type OmitValueType<T, V> = {
[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;
margin: 8px;
flex-wrap: wrap;
& > div {
overflow: hidden;
display: flex;
}
}
&__uploads {
@ -772,7 +767,6 @@ body > [data-popper-placement] {
align-items: center;
flex: 1 1 auto;
max-width: 100%;
overflow: hidden;
}
&__buttons {
@ -932,6 +926,11 @@ body > [data-popper-placement] {
text-overflow: ellipsis;
white-space: nowrap;
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
&[disabled] {
cursor: default;
color: var(--color-text-disabled);

View File

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

View File

@ -275,7 +275,7 @@ class Request
end
if ::HTTP::Response.methods.include?(:body_with_limit) && !Rails.env.production?
abort 'HTTP::Response#body_with_limit is already defined, the monkey patch will not be applied'
raise 'HTTP::Response#body_with_limit is already defined, the monkey patch will not be applied'
else
class ::HTTP::Response
include Request::ClientLimit

View File

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

View File

@ -123,7 +123,11 @@ module Account::Interactions
end
def following?(other_account)
active_relationships.exists?(target_account: other_account)
other_id = other_account.is_a?(Account) ? other_account.id : other_account
preloaded_relation(:following, other_id) do
active_relationships.exists?(target_account: other_account)
end
end
def following_anyone?
@ -139,15 +143,33 @@ module Account::Interactions
end
def blocking?(other_account)
block_relationships.exists?(target_account: other_account)
other_id = other_account.is_a?(Account) ? other_account.id : other_account
preloaded_relation(:blocking, other_id) do
block_relationships.exists?(target_account: other_account)
end
end
def blocked_by?(other_account)
other_id = other_account.is_a?(Account) ? other_account.id : other_account
preloaded_relation(:blocked_by, other_id) do
other_account.block_relationships.exists?(target_account: self)
end
end
def domain_blocking?(other_domain)
domain_blocks.exists?(domain: other_domain)
preloaded_relation(:domain_blocking_by_domain, other_domain) do
domain_blocks.exists?(domain: other_domain)
end
end
def muting?(other_account)
mute_relationships.exists?(target_account: other_account)
other_id = other_account.is_a?(Account) ? other_account.id : other_account
preloaded_relation(:muting, other_id) do
mute_relationships.exists?(target_account: other_account)
end
end
def muting_conversation?(conversation)
@ -226,4 +248,10 @@ module Account::Interactions
def normalized_domain(domain)
TagManager.instance.normalize_domain(domain)
end
private
def preloaded_relation(type, key)
@preloaded_relations && @preloaded_relations[type] ? @preloaded_relations[type][key].present? : yield
end
end

View File

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

View File

@ -26,7 +26,7 @@ module Status::InteractionPolicyConcern
end
# Returns `:automatic`, `:manual`, `:unknown` or `:denied`
def quote_policy_for_account(other_account, preloaded_relations: {})
def quote_policy_for_account(other_account)
return :denied if other_account.nil? || direct_visibility? || reblog?
following_author = nil
@ -41,7 +41,7 @@ module Status::InteractionPolicyConcern
return :automatic if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public])
if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers])
following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil?
following_author = other_account.following?(account) if following_author.nil?
return :automatic if following_author
end
@ -54,7 +54,7 @@ module Status::InteractionPolicyConcern
return :manual if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public])
if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers])
following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil?
following_author = other_account.following?(account) if following_author.nil?
return :manual if following_author
end

View File

@ -8,9 +8,10 @@ module Status::ThreadingConcern
statuses = Status.with_accounts(ids).to_a
account_ids = statuses.map(&:account_id).uniq
domains = statuses.filter_map(&:account_domain).uniq
relations = account&.relations_map(account_ids, domains) || {}
statuses.reject! { |status| StatusFilter.new(status, account, relations).filtered? }
account&.preload_relations!(account_ids, domains)
statuses.reject! { |status| StatusFilter.new(status, account).filtered? }
if stable
statuses.sort_by! { |status| ids.index(status.id) }

View File

@ -66,6 +66,6 @@ class AccountPolicy < ApplicationPolicy
end
def feature?
record.featureable? && !current_account.blocking?(record) && !record.blocking?(current_account)
record.featureable? && !current_account.blocking?(record) && !current_account.blocked_by?(record)
end
end

View File

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

View File

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

View File

@ -41,14 +41,8 @@ class StatusRelationshipsPresenter
end
# This one is currently on-demand as it is only used for quote posts
def preloaded_account_relations
@preloaded_account_relations ||= begin
accounts = @statuses.compact.flat_map { |s| [s.account, s.proper.account, s.proper.quote&.quoted_account] }.uniq.compact
account_ids = accounts.pluck(:id)
account_domains = accounts.pluck(:domain).uniq
Account.find(@current_account_id).relations_map(account_ids, account_domains)
end
def authoring_accounts
@authoring_accounts ||= @statuses.compact.flat_map { |s| [s.account, s.proper.account, s.proper.quote&.quoted_account] }.uniq.compact
end
private

View File

@ -19,6 +19,13 @@ class REST::BaseQuoteSerializer < ActiveModel::Serializer
private
def status_filter
@status_filter ||= StatusFilter.new(object.quoted_status, current_user&.account, instance_options[:relationships]&.preloaded_account_relations || {})
@status_filter ||= begin
if current_user && instance_options[:relationships]
account_ids = instance_options[:relationships].authoring_accounts.pluck(:id)
domains = instance_options[:relationships].authoring_accounts.pluck(:domain).uniq
current_user.account.preload_relations!(account_ids, domains)
end
StatusFilter.new(object.quoted_status, current_user&.account)
end
end
end

View File

@ -2,9 +2,10 @@
class CreateCollectionService
def call(params, account)
account_ids = params.delete(:account_ids)
@account = account
@accounts_to_add = Account.find(params.delete(:account_ids) || [])
@collection = Collection.new(params.merge({ account:, local: true }))
build_items(account_ids)
build_items
@collection.save!
@collection
@ -12,13 +13,14 @@ class CreateCollectionService
private
def build_items(account_ids)
return if account_ids.blank?
def build_items
return if @accounts_to_add.empty?
account_ids.each do |account_id|
account = Account.find(account_id)
# TODO: validate preferences
@collection.collection_items.build(account:)
@account.preload_relations!(@accounts_to_add.map(&:id))
@accounts_to_add.each do |account_to_add|
raise Mastodon::NotPermittedError, I18n.t('accounts.errors.cannot_be_added_to_collections') unless AccountPolicy.new(@account, account_to_add).feature?
@collection.collection_items.build(account: account_to_add)
end
end
end

View File

@ -29,9 +29,10 @@ class StatusesSearchService < BaseService
results = request.collapse(field: :id).order(id: { order: :desc }).limit(@limit).offset(@offset).objects.compact
account_ids = results.map(&:account_id)
account_domains = results.map(&:account_domain)
preloaded_relations = @account.relations_map(account_ids, account_domains)
results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
@account.preload_relations!(account_ids, account_domains)
results.reject { |status| StatusFilter.new(status, @account).filtered? }
rescue Faraday::ConnectionFailed, Parslet::ParseFailed, Errno::ENETUNREACH
[]
end

View File

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

View File

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

View File

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

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

View File

@ -6,7 +6,7 @@ if Rails.configuration.x.use_vips
require 'vips'
unless Vips.at_least_libvips?(8, 13)
abort <<~ERROR.squish
abort <<~ERROR.squish # rubocop:disable Rails/Exit
Incompatible libvips version (#{Vips.version_string}), please install libvips >= 8.13
ERROR
end

View File

@ -13,7 +13,7 @@ namespace :api, format: false do
resources :async_refreshes, only: :show
resources :collections, only: [:show, :create, :update, :destroy] do
resources :items, only: [:create], controller: 'collection_items'
resources :items, only: [:create, :destroy], controller: 'collection_items'
end
end

View File

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

View File

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

View File

@ -302,9 +302,24 @@ RSpec.describe Account::Interactions do
subject { account.following?(target_account) }
context 'when following target_account' do
it 'returns true' do
before do
account.active_relationships.create(target_account: target_account)
expect(subject).to be true
end
it 'returns true' do
result = nil
expect { result = subject }.to execute_queries
expect(result).to be true
end
context 'when relations are preloaded' do
it 'does not query the database to get the result' do
account.preload_relations!([target_account.id])
result = nil
expect { result = subject }.to_not execute_queries
expect(result).to be true
end
end
end
@ -336,9 +351,26 @@ RSpec.describe Account::Interactions do
subject { account.blocking?(target_account) }
context 'when blocking target_account' do
it 'returns true' do
before do
account.block_relationships.create(target_account: target_account)
expect(subject).to be true
end
it 'returns true' do
result = nil
expect { result = subject }.to execute_queries
expect(result).to be true
end
context 'when relations are preloaded' do
it 'does not query the database to get the result' do
account.preload_relations!([target_account.id])
result = nil
expect { result = subject }.to_not execute_queries
expect(result).to be true
end
end
end
@ -349,16 +381,65 @@ RSpec.describe Account::Interactions do
end
end
describe '#blocked_by?' do
subject { account.blocked_by?(target_account) }
context 'when blocked by target_account' do
before do
target_account.block_relationships.create(target_account: account)
end
it 'returns true' do
result = nil
expect { result = subject }.to execute_queries
expect(result).to be true
end
context 'when relations are preloaded' do
it 'does not query the database to get the result' do
account.preload_relations!([target_account.id])
result = nil
expect { result = subject }.to_not execute_queries
expect(result).to be true
end
end
end
context 'when not blocked by target_account' do
it 'returns false' do
expect(subject).to be false
end
end
end
describe '#domain_blocking?' do
subject { account.domain_blocking?(domain) }
let(:domain) { 'example.com' }
context 'when blocking the domain' do
it 'returns true' do
before do
account_domain_block = Fabricate(:account_domain_block, domain: domain)
account.domain_blocks << account_domain_block
expect(subject).to be true
end
it 'returns true' do
result = nil
expect { result = subject }.to execute_queries
expect(result).to be true
end
context 'when relations are preloaded' do
it 'does not query the database to get the result' do
account.preload_relations!([], [domain])
result = nil
expect { result = subject }.to_not execute_queries
expect(result).to be true
end
end
end
@ -373,10 +454,25 @@ RSpec.describe Account::Interactions do
subject { account.muting?(target_account) }
context 'when muting target_account' do
it 'returns true' do
before do
mute = Fabricate(:mute, account: account, target_account: target_account)
account.mute_relationships << mute
expect(subject).to be true
end
it 'returns true' do
result = nil
expect { result = subject }.to execute_queries
expect(result).to be true
end
context 'when relations are preloaded' do
it 'does not query the database to get the result' do
account.preload_relations!([target_account.id])
result = nil
expect { result = subject }.to_not execute_queries
expect(result).to be true
end
end
end

View File

@ -52,4 +52,53 @@ RSpec.describe 'Api::V1Alpha::CollectionItems', feature: :collections do
end
end
end
describe 'DELETE /api/v1_alpha/collections/:collection_id/items/:id' do
subject do
delete "/api/v1_alpha/collections/#{collection.id}/items/#{item.id}", headers: headers
end
let(:collection) { Fabricate(:collection, account: user.account) }
let(:item) { Fabricate(:collection_item, collection:) }
it_behaves_like 'forbidden for wrong scope', 'read'
context 'when user is owner of the collection' do
context 'when item belongs to collection' do
it 'deletes the collection item and returns http success' do
item # Make sure this exists before calling the API
expect do
subject
end.to change(collection.collection_items, :count).by(-1)
expect(response).to have_http_status(200)
end
end
context 'when item does not belong to to collection' do
let(:item) { Fabricate(:collection_item) }
it 'returns http not found' do
item # Make sure this exists before calling the API
expect do
subject
end.to_not change(CollectionItem, :count)
expect(response).to have_http_status(404)
end
end
end
context 'when user is not the owner of the collection' do
let(:collection) { Fabricate(:collection) }
it 'returns http forbidden' do
subject
expect(response).to have_http_status(403)
end
end
end
end

View File

@ -30,9 +30,10 @@ RSpec.describe CreateCollectionService do
end
context 'when given account ids' do
let(:account_ids) do
Fabricate.times(2, :account).map { |a| a.id.to_s }
let(:accounts) do
Fabricate.times(2, :account)
end
let(:account_ids) { accounts.map { |a| a.id.to_s } }
let(:params) do
base_params.merge(account_ids:)
end
@ -42,6 +43,18 @@ RSpec.describe CreateCollectionService do
subject.call(params, author)
end.to change(CollectionItem, :count).by(2)
end
context 'when one account may not be added' do
before do
accounts.last.update(discoverable: false)
end
it 'raises an error' do
expect do
subject.call(params, author)
end.to raise_error(Mastodon::NotPermittedError)
end
end
end
context 'when given a tag' do

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