mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-25 20:06:30 +00:00
Merge commit 'cbe135210305c0ce4455130c4e75680e53ff1425' into glitch-soc/merge-upstream
Conflicts: - `app/views/wrapstodon/show.html.haml`: Conflict caused by glitch-soc's theming system. Updated upstream changes.
This commit is contained in:
@@ -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 '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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user