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