Merge pull request #3236 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 50743cc35be19deae9356bd21f1143e3a9b4fbb8
This commit is contained in:
commit
b4aae2262b
@ -50,9 +50,13 @@ const preview: Preview = {
|
||||
locale: 'en',
|
||||
},
|
||||
decorators: [
|
||||
(Story, { parameters, globals }) => {
|
||||
(Story, { parameters, globals, args }) => {
|
||||
// Get the locale from the global toolbar
|
||||
// and merge it with any parameters or args state.
|
||||
const { locale } = globals as { locale: string };
|
||||
const { state = {} } = parameters;
|
||||
const { state: argsState = {} } = args;
|
||||
|
||||
const reducer = reducerWithInitialState(
|
||||
{
|
||||
meta: {
|
||||
@ -60,7 +64,9 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
state as Record<string, unknown>,
|
||||
argsState as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
middleware(getDefaultMiddleware) {
|
||||
|
||||
@ -837,7 +837,7 @@ GEM
|
||||
stoplight (5.3.8)
|
||||
zeitwerk
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.5.0)
|
||||
strong_migrations (2.5.1)
|
||||
activerecord (>= 7.1)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
|
||||
@ -21,6 +21,10 @@ import { importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
quoteErrorEdit: {
|
||||
id: 'quote_error.edit',
|
||||
defaultMessage: 'Quotes cannot be added when editing a post.',
|
||||
},
|
||||
quoteErrorUpload: {
|
||||
id: 'quote_error.upload',
|
||||
defaultMessage: 'Quoting is not allowed with media attachments.',
|
||||
@ -124,7 +128,9 @@ export const quoteComposeByStatus = createAppThunk(
|
||||
false,
|
||||
);
|
||||
|
||||
if (composeState.get('poll')) {
|
||||
if (composeState.get('id')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorEdit }));
|
||||
} else if (composeState.get('poll')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||
} else if (
|
||||
composeState.get('is_uploading') ||
|
||||
@ -184,7 +190,8 @@ export const pasteLinkCompose = createDataLoadingThunk(
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading')
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id')
|
||||
)
|
||||
return;
|
||||
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
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 };
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Emoji',
|
||||
component: Emoji,
|
||||
args: {
|
||||
code: '🖤',
|
||||
state: 'auto',
|
||||
},
|
||||
argTypes: {
|
||||
code: {
|
||||
name: 'Emoji',
|
||||
},
|
||||
state: {
|
||||
control: {
|
||||
type: 'select',
|
||||
labels: {
|
||||
auto: 'Auto',
|
||||
native: 'Native',
|
||||
twemoji: 'Twemoji',
|
||||
},
|
||||
},
|
||||
options: ['auto', 'native', 'twemoji'],
|
||||
name: 'Emoji Style',
|
||||
mapping: {
|
||||
auto: { meta: { emoji_style: 'auto' } },
|
||||
native: { meta: { emoji_style: 'native' } },
|
||||
twemoji: { meta: { emoji_style: 'twemoji' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
render(args) {
|
||||
void importCustomEmojiData();
|
||||
return <Emoji {...args} />;
|
||||
},
|
||||
} satisfies Meta<EmojiProps>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const CustomEmoji: Story = {
|
||||
args: {
|
||||
code: ':custom:',
|
||||
},
|
||||
};
|
||||
@ -14,7 +14,7 @@ import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
||||
import { textToEmojis } from './index';
|
||||
|
||||
interface EmojiHTMLProps {
|
||||
export interface EmojiHTMLProps {
|
||||
htmlString: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
className?: string;
|
||||
|
||||
@ -2,7 +2,7 @@ import type { FC } from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { EMOJI_TYPE_CUSTOM } from '@/flavours/glitch/features/emoji/constants';
|
||||
import { useEmojiAppState } from '@/flavours/glitch/features/emoji/hooks';
|
||||
import { useEmojiAppState } from '@/flavours/glitch/features/emoji/mode';
|
||||
import { unicodeHexToUrl } from '@/flavours/glitch/features/emoji/normalize';
|
||||
import {
|
||||
isStateLoaded,
|
||||
|
||||
@ -7,23 +7,49 @@ const meta = {
|
||||
title: 'Components/HTMLBlock',
|
||||
component: HTMLBlock,
|
||||
args: {
|
||||
contents:
|
||||
'<p>Hello, world!</p>\n<p><a href="#">A link</a></p>\n<p>This should be filtered out: <button>Bye!</button></p>',
|
||||
htmlString: `<p>Hello, world!</p>
|
||||
<p><a href="#">A link</a></p>
|
||||
<p>This should be filtered out: <button>Bye!</button></p>
|
||||
<p>This also has emoji: 🖤</p>`,
|
||||
},
|
||||
argTypes: {
|
||||
extraEmojis: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
onElement: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
onAttribute: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
// Just for visual clarity in Storybook.
|
||||
<div
|
||||
<HTMLBlock
|
||||
{...args}
|
||||
style={{
|
||||
border: '1px solid black',
|
||||
padding: '1rem',
|
||||
minWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<HTMLBlock {...args} />
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Force Twemoji to demonstrate emoji rendering.
|
||||
parameters: {
|
||||
state: {
|
||||
meta: {
|
||||
emoji_style: 'twemoji',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof HTMLBlock>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@ -1,50 +1,30 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { cleanExtraEmojis } from '@/flavours/glitch/features/emoji/normalize';
|
||||
import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types';
|
||||
import { createLimitedCache } from '@/flavours/glitch/utils/cache';
|
||||
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import { htmlStringToComponents } from '../../utils/html';
|
||||
import type { EmojiHTMLProps } from '../emoji/html';
|
||||
import { ModernEmojiHTML } from '../emoji/html';
|
||||
import { useElementHandledLink } from '../status/handled_link';
|
||||
|
||||
// Use a module-level cache to avoid re-rendering the same HTML multiple times.
|
||||
const cache = createLimitedCache<ReactNode>({ maxSize: 1000 });
|
||||
|
||||
interface HTMLBlockProps {
|
||||
contents: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
}
|
||||
|
||||
export const HTMLBlock: FC<HTMLBlockProps> = ({
|
||||
contents: raw,
|
||||
extraEmojis,
|
||||
}) => {
|
||||
const customEmojis = useMemo(
|
||||
() => cleanExtraEmojis(extraEmojis),
|
||||
[extraEmojis],
|
||||
);
|
||||
const contents = useMemo(() => {
|
||||
const key = JSON.stringify({ raw, customEmojis });
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
const rendered = htmlStringToComponents(raw, {
|
||||
onText,
|
||||
extraArgs: { customEmojis },
|
||||
export const HTMLBlock = polymorphicForwardRef<
|
||||
'div',
|
||||
EmojiHTMLProps & Parameters<typeof useElementHandledLink>[0]
|
||||
>(
|
||||
({
|
||||
onElement: onParentElement,
|
||||
hrefToMention,
|
||||
hashtagAccountId,
|
||||
...props
|
||||
}) => {
|
||||
const { onElement: onLinkElement } = useElementHandledLink({
|
||||
hrefToMention,
|
||||
hashtagAccountId,
|
||||
});
|
||||
|
||||
cache.set(key, rendered);
|
||||
return rendered;
|
||||
}, [raw, customEmojis]);
|
||||
|
||||
return contents;
|
||||
};
|
||||
|
||||
function onText(
|
||||
text: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Doesn't do anything, just showing how typing would work.
|
||||
{ customEmojis }: { customEmojis: CustomEmojiMapArg | null },
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
const onElement: OnElementHandler = useCallback(
|
||||
(...args) => onParentElement?.(...args) ?? onLinkElement(...args),
|
||||
[onLinkElement, onParentElement],
|
||||
);
|
||||
return <ModernEmojiHTML {...props} onElement={onElement} />;
|
||||
},
|
||||
);
|
||||
|
||||
@ -7,7 +7,7 @@ import { assetHost } from 'flavours/glitch/utils/config';
|
||||
|
||||
import { EMOJI_MODE_NATIVE } from './constants';
|
||||
import EmojiData from './emoji_data.json';
|
||||
import { useEmojiAppState } from './hooks';
|
||||
import { useEmojiAppState } from './mode';
|
||||
|
||||
const backgroundImageFnDefault = () => `${assetHost}/emoji/sheet_15_1.png`;
|
||||
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
|
||||
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
||||
|
||||
import { toSupportedLocale } from './locale';
|
||||
import { determineEmojiMode } from './mode';
|
||||
import { cleanExtraEmojis } from './normalize';
|
||||
import { emojifyElement, emojifyText } from './render';
|
||||
import type { CustomEmojiMapArg, EmojiAppState } from './types';
|
||||
import { stringHasAnyEmoji } from './utils';
|
||||
|
||||
interface UseEmojifyOptions {
|
||||
text: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
deep?: boolean;
|
||||
}
|
||||
|
||||
export function useEmojify({
|
||||
text,
|
||||
extraEmojis,
|
||||
deep = true,
|
||||
}: UseEmojifyOptions) {
|
||||
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||
|
||||
const appState = useEmojiAppState();
|
||||
const extra = useMemo(() => cleanExtraEmojis(extraEmojis), [extraEmojis]);
|
||||
|
||||
const emojify = useCallback(
|
||||
async (input: string) => {
|
||||
let result: string | null = null;
|
||||
if (deep) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = input;
|
||||
if (await emojifyElement(wrapper, appState, extra ?? {})) {
|
||||
result = wrapper.innerHTML;
|
||||
}
|
||||
} else {
|
||||
result = await emojifyText(text, appState, extra ?? {});
|
||||
}
|
||||
if (result) {
|
||||
setEmojifiedText(result);
|
||||
} else {
|
||||
setEmojifiedText(input);
|
||||
}
|
||||
},
|
||||
[appState, deep, extra, text],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
||||
void emojify(text);
|
||||
} else {
|
||||
// If no emoji or we don't want to render, fall back.
|
||||
setEmojifiedText(text);
|
||||
}
|
||||
}, [emojify, text]);
|
||||
|
||||
return emojifiedText;
|
||||
}
|
||||
|
||||
const modeSelector = createAppSelector(
|
||||
[(state) => state.meta.get('emoji_style') as string],
|
||||
(emoji_style) => determineEmojiMode(emoji_style),
|
||||
);
|
||||
|
||||
export function useEmojiAppState(): EmojiAppState {
|
||||
const locale = useAppSelector((state) =>
|
||||
toSupportedLocale(state.meta.get('locale') as string),
|
||||
);
|
||||
const mode = useAppSelector(modeSelector);
|
||||
|
||||
return {
|
||||
currentLocale: locale,
|
||||
locales: [locale],
|
||||
mode,
|
||||
darkTheme: document.body.classList.contains('theme-default'),
|
||||
};
|
||||
}
|
||||
@ -10,6 +10,8 @@ let worker: Worker | null = null;
|
||||
|
||||
const log = emojiLogger('index');
|
||||
|
||||
const WORKER_TIMEOUT = 1_000; // 1 second
|
||||
|
||||
export function initializeEmoji() {
|
||||
log('initializing emojis');
|
||||
if (!worker && 'Worker' in window) {
|
||||
@ -29,7 +31,7 @@ export function initializeEmoji() {
|
||||
log('worker is not ready after timeout');
|
||||
worker = null;
|
||||
void fallbackLoad();
|
||||
}, 500);
|
||||
}, WORKER_TIMEOUT);
|
||||
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
const { data: message } = event;
|
||||
if (message === 'ready') {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// Credit to Nolan Lawson for the original implementation.
|
||||
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
|
||||
|
||||
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
|
||||
import { isDevelopment } from '@/flavours/glitch/utils/environment';
|
||||
|
||||
import {
|
||||
@ -8,7 +9,27 @@ import {
|
||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
EMOJI_MODE_TWEMOJI,
|
||||
} from './constants';
|
||||
import type { EmojiMode } from './types';
|
||||
import { toSupportedLocale } from './locale';
|
||||
import type { EmojiAppState, EmojiMode } from './types';
|
||||
|
||||
const modeSelector = createAppSelector(
|
||||
[(state) => state.meta.get('emoji_style') as string],
|
||||
(emoji_style) => determineEmojiMode(emoji_style),
|
||||
);
|
||||
|
||||
export function useEmojiAppState(): EmojiAppState {
|
||||
const locale = useAppSelector((state) =>
|
||||
toSupportedLocale(state.meta.get('locale') as string),
|
||||
);
|
||||
const mode = useAppSelector(modeSelector);
|
||||
|
||||
return {
|
||||
currentLocale: locale,
|
||||
locales: [locale],
|
||||
mode,
|
||||
darkTheme: document.body.classList.contains('theme-default'),
|
||||
};
|
||||
}
|
||||
|
||||
type Feature = Uint8ClampedArray;
|
||||
|
||||
|
||||
@ -1,101 +1,12 @@
|
||||
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||
|
||||
import { EMOJI_MODE_TWEMOJI } from './constants';
|
||||
import * as db from './database';
|
||||
import * as loader from './loader';
|
||||
import {
|
||||
emojifyElement,
|
||||
emojifyText,
|
||||
testCacheClear,
|
||||
loadEmojiDataToState,
|
||||
stringToEmojiState,
|
||||
tokenizeText,
|
||||
} from './render';
|
||||
import type { EmojiAppState } from './types';
|
||||
|
||||
function mockDatabase() {
|
||||
return {
|
||||
searchCustomEmojisByShortcodes: vi
|
||||
.spyOn(db, 'searchCustomEmojisByShortcodes')
|
||||
.mockResolvedValue([customEmojiFactory()]),
|
||||
searchEmojisByHexcodes: vi
|
||||
.spyOn(db, 'searchEmojisByHexcodes')
|
||||
.mockResolvedValue([
|
||||
unicodeEmojiFactory({
|
||||
hexcode: '1F60A',
|
||||
label: 'smiling face with smiling eyes',
|
||||
unicode: '😊',
|
||||
}),
|
||||
unicodeEmojiFactory({
|
||||
hexcode: '1F1EA-1F1FA',
|
||||
label: 'flag-eu',
|
||||
unicode: '🇪🇺',
|
||||
}),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const expectedSmileImage =
|
||||
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
||||
const expectedFlagImage =
|
||||
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
||||
|
||||
function testAppState(state: Partial<EmojiAppState> = {}) {
|
||||
return {
|
||||
locales: ['en'],
|
||||
mode: EMOJI_MODE_TWEMOJI,
|
||||
currentLocale: 'en',
|
||||
darkTheme: false,
|
||||
...state,
|
||||
} satisfies EmojiAppState;
|
||||
}
|
||||
|
||||
describe('emojifyElement', () => {
|
||||
function testElement(text = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>') {
|
||||
const testElement = document.createElement('div');
|
||||
testElement.innerHTML = text;
|
||||
return testElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
testCacheClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('caches element rendering results', async () => {
|
||||
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||
mockDatabase();
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
expect(searchEmojisByHexcodes).toHaveBeenCalledExactlyOnceWith(
|
||||
['1F1EA-1F1FA', '1F60A'],
|
||||
'en',
|
||||
);
|
||||
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
|
||||
':custom:',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns null when no emoji are found', async () => {
|
||||
mockDatabase();
|
||||
const actual = await emojifyElement(
|
||||
testElement('<p>here is just text :)</p>'),
|
||||
testAppState(),
|
||||
);
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emojifyText', () => {
|
||||
test('returns original input when no emoji are in string', async () => {
|
||||
const actual = await emojifyText('nothing here', testAppState());
|
||||
expect(actual).toBe('nothing here');
|
||||
});
|
||||
|
||||
test('renders Unicode emojis to twemojis', async () => {
|
||||
mockDatabase();
|
||||
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
|
||||
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeText', () => {
|
||||
test('returns an array of text to be a single token', () => {
|
||||
@ -162,3 +73,106 @@ describe('tokenizeText', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringToEmojiState', () => {
|
||||
test('returns unicode emoji state for valid unicode emoji', () => {
|
||||
expect(stringToEmojiState('😊')).toEqual({
|
||||
type: 'unicode',
|
||||
code: '1F60A',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns custom emoji state for valid custom emoji', () => {
|
||||
expect(stringToEmojiState(':smile:')).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns custom emoji state with data when provided', () => {
|
||||
const customEmoji = {
|
||||
smile: customEmojiFactory({
|
||||
shortcode: 'smile',
|
||||
url: 'https://example.com/smile.png',
|
||||
static_url: 'https://example.com/smile_static.png',
|
||||
}),
|
||||
};
|
||||
expect(stringToEmojiState(':smile:', customEmoji)).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: customEmoji.smile,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for invalid emoji strings', () => {
|
||||
expect(stringToEmojiState('notanemoji')).toBeNull();
|
||||
expect(stringToEmojiState(':invalid-emoji:')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadEmojiDataToState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('loads unicode data into state', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadEmojiByHexcode')
|
||||
.mockResolvedValue(unicodeEmojiFactory());
|
||||
const unicodeState = { type: 'unicode', code: '1F60A' } as const;
|
||||
const result = await loadEmojiDataToState(unicodeState, 'en');
|
||||
expect(dbCall).toHaveBeenCalledWith('1F60A', 'en');
|
||||
expect(result).toEqual({
|
||||
type: 'unicode',
|
||||
code: '1F60A',
|
||||
data: unicodeEmojiFactory(),
|
||||
});
|
||||
});
|
||||
|
||||
test('loads custom emoji data into state', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadCustomEmojiByShortcode')
|
||||
.mockResolvedValueOnce(customEmojiFactory());
|
||||
const customState = { type: 'custom', code: 'smile' } as const;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(dbCall).toHaveBeenCalledWith('smile');
|
||||
expect(result).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: customEmojiFactory(),
|
||||
});
|
||||
});
|
||||
|
||||
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 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 result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('retries loading emoji data once if initial load fails', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadEmojiByHexcode')
|
||||
.mockRejectedValue(new db.LocaleNotLoadedError('en'));
|
||||
vi.spyOn(loader, 'importEmojiData').mockResolvedValueOnce();
|
||||
const consoleCall = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementationOnce(() => null);
|
||||
|
||||
const unicodeState = { type: 'unicode', code: '1F60A' } as const;
|
||||
const result = await loadEmojiDataToState(unicodeState, 'en');
|
||||
|
||||
expect(dbCall).toHaveBeenCalledTimes(2);
|
||||
expect(loader.importEmojiData).toHaveBeenCalledWith('en');
|
||||
expect(consoleCall).toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,3 @@
|
||||
import { autoPlayGif } from '@/flavours/glitch/initial_state';
|
||||
import { createLimitedCache } from '@/flavours/glitch/utils/cache';
|
||||
import * as perf from '@/flavours/glitch/utils/performance';
|
||||
|
||||
import {
|
||||
EMOJI_MODE_NATIVE,
|
||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
@ -12,33 +8,69 @@ import {
|
||||
loadCustomEmojiByShortcode,
|
||||
loadEmojiByHexcode,
|
||||
LocaleNotLoadedError,
|
||||
searchCustomEmojisByShortcodes,
|
||||
searchEmojisByHexcodes,
|
||||
} from './database';
|
||||
import { importEmojiData } from './loader';
|
||||
import { emojiToUnicodeHex, unicodeHexToUrl } from './normalize';
|
||||
import { emojiToUnicodeHex } from './normalize';
|
||||
import type {
|
||||
EmojiAppState,
|
||||
EmojiLoadedState,
|
||||
EmojiMode,
|
||||
EmojiState,
|
||||
EmojiStateCustom,
|
||||
EmojiStateMap,
|
||||
EmojiStateUnicode,
|
||||
ExtraCustomEmojiMap,
|
||||
LocaleOrCustom,
|
||||
} from './types';
|
||||
import {
|
||||
anyEmojiRegex,
|
||||
emojiLogger,
|
||||
isCustomEmoji,
|
||||
isUnicodeEmoji,
|
||||
stringHasAnyEmoji,
|
||||
stringHasUnicodeFlags,
|
||||
} from './utils';
|
||||
|
||||
const log = emojiLogger('render');
|
||||
|
||||
type TokenizedText = (string | EmojiState)[];
|
||||
|
||||
/**
|
||||
* Tokenizes text into strings and emoji states.
|
||||
* @param text Text to tokenize.
|
||||
* @returns Array of strings and emoji states.
|
||||
*/
|
||||
export function tokenizeText(text: string): TokenizedText {
|
||||
if (!text.trim()) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const tokens = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(anyEmojiRegex())) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const code = match[0];
|
||||
|
||||
if (code.startsWith(':') && code.endsWith(':')) {
|
||||
// Custom emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code,
|
||||
} satisfies EmojiStateCustom);
|
||||
} else {
|
||||
// Unicode emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
code: code,
|
||||
} satisfies EmojiStateUnicode);
|
||||
}
|
||||
lastIndex = match.index + code.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push(text.slice(lastIndex));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses emoji string to extract emoji state.
|
||||
* @param code Hex code or custom shortcode.
|
||||
@ -132,305 +164,19 @@ export function isStateLoaded(state: EmojiState): state is EmojiLoadedState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
||||
* Determines if the given token should be rendered as an image based on the emoji mode.
|
||||
* @param state Emoji state to parse.
|
||||
* @param mode Rendering mode.
|
||||
* @returns Whether to render as an image.
|
||||
*/
|
||||
export async function emojifyElement<Element extends HTMLElement>(
|
||||
element: Element,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<Element | null> {
|
||||
const cacheKey = createCacheKey(element, appState, extraEmojis);
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
log('Cache hit on %s', element.outerHTML);
|
||||
if (cached === null) {
|
||||
return null;
|
||||
}
|
||||
element.innerHTML = cached;
|
||||
return element;
|
||||
}
|
||||
if (!stringHasAnyEmoji(element.innerHTML)) {
|
||||
updateCache(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
perf.start('emojifyElement()');
|
||||
const queue: (HTMLElement | Text)[] = [element];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (
|
||||
!current ||
|
||||
current instanceof HTMLScriptElement ||
|
||||
current instanceof HTMLStyleElement
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
current.textContent &&
|
||||
(current instanceof Text || !current.hasChildNodes())
|
||||
) {
|
||||
const renderedContent = await textToElementArray(
|
||||
current.textContent,
|
||||
appState,
|
||||
extraEmojis,
|
||||
);
|
||||
if (renderedContent) {
|
||||
if (!(current instanceof Text)) {
|
||||
current.textContent = null; // Clear the text content if it's not a Text node.
|
||||
}
|
||||
current.replaceWith(renderedToHTML(renderedContent));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of current.childNodes) {
|
||||
if (child instanceof HTMLElement || child instanceof Text) {
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateCache(cacheKey, element.innerHTML);
|
||||
perf.stop('emojifyElement()');
|
||||
return element;
|
||||
}
|
||||
|
||||
export async function emojifyText(
|
||||
text: string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<string | null> {
|
||||
const cacheKey = createCacheKey(text, appState, extraEmojis);
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
log('Cache hit on %s', text);
|
||||
return cached ?? text;
|
||||
}
|
||||
if (!stringHasAnyEmoji(text)) {
|
||||
updateCache(cacheKey, null);
|
||||
return text;
|
||||
}
|
||||
const eleArray = await textToElementArray(text, appState, extraEmojis);
|
||||
if (!eleArray) {
|
||||
updateCache(cacheKey, null);
|
||||
return text;
|
||||
}
|
||||
const rendered = renderedToHTML(eleArray, document.createElement('div'));
|
||||
updateCache(cacheKey, rendered.innerHTML);
|
||||
return rendered.innerHTML;
|
||||
}
|
||||
|
||||
// Private functions
|
||||
|
||||
const {
|
||||
set: updateCache,
|
||||
get: getCached,
|
||||
clear: cacheClear,
|
||||
} = createLimitedCache<string | null>({ log: log.extend('cache') });
|
||||
|
||||
function createCacheKey(
|
||||
input: HTMLElement | string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap,
|
||||
) {
|
||||
return JSON.stringify([
|
||||
input instanceof HTMLElement ? input.outerHTML : input,
|
||||
appState,
|
||||
extraEmojis,
|
||||
]);
|
||||
}
|
||||
|
||||
type EmojifiedTextArray = (string | HTMLImageElement)[];
|
||||
|
||||
async function textToElementArray(
|
||||
text: string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<EmojifiedTextArray | null> {
|
||||
// Exit if no text to convert.
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = tokenizeText(text);
|
||||
|
||||
// If only one token and it's a string, exit early.
|
||||
if (tokens.length === 1 && typeof tokens[0] === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all emoji from the state map, loading any missing ones.
|
||||
await loadMissingEmojiIntoCache(tokens, appState, extraEmojis);
|
||||
|
||||
const renderedFragments: EmojifiedTextArray = [];
|
||||
for (const token of tokens) {
|
||||
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
|
||||
let state: EmojiState | undefined;
|
||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||
const extraEmojiData = extraEmojis[token.code];
|
||||
if (extraEmojiData) {
|
||||
state = {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
data: extraEmojiData,
|
||||
code: token.code,
|
||||
};
|
||||
} else {
|
||||
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
|
||||
}
|
||||
} else {
|
||||
state = emojiForLocale(
|
||||
emojiToUnicodeHex(token.code),
|
||||
appState.currentLocale,
|
||||
);
|
||||
}
|
||||
|
||||
// If the state is valid, create an image element. Otherwise, just append as text.
|
||||
if (state && typeof state !== 'string' && isStateLoaded(state)) {
|
||||
const image = stateToImage(state, appState);
|
||||
renderedFragments.push(image);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const text = typeof token === 'string' ? token : token.code;
|
||||
renderedFragments.push(text);
|
||||
}
|
||||
|
||||
return renderedFragments;
|
||||
}
|
||||
|
||||
type TokenizedText = (string | EmojiState)[];
|
||||
|
||||
export function tokenizeText(text: string): TokenizedText {
|
||||
if (!text.trim()) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const tokens = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(anyEmojiRegex())) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const code = match[0];
|
||||
|
||||
if (code.startsWith(':') && code.endsWith(':')) {
|
||||
// Custom emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code,
|
||||
} satisfies EmojiStateCustom);
|
||||
} else {
|
||||
// Unicode emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
code: code,
|
||||
} satisfies EmojiStateUnicode);
|
||||
}
|
||||
lastIndex = match.index + code.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push(text.slice(lastIndex));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
||||
[
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
createLimitedCache<EmojiState>({ log: log.extend('custom') }),
|
||||
],
|
||||
]);
|
||||
|
||||
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
|
||||
return (
|
||||
localeCacheMap.get(locale) ??
|
||||
createLimitedCache<EmojiState>({ log: log.extend(locale) })
|
||||
);
|
||||
}
|
||||
|
||||
function emojiForLocale(
|
||||
code: string,
|
||||
locale: LocaleOrCustom,
|
||||
): EmojiState | undefined {
|
||||
const cache = cacheForLocale(locale);
|
||||
return cache.get(code);
|
||||
}
|
||||
|
||||
async function loadMissingEmojiIntoCache(
|
||||
tokens: TokenizedText,
|
||||
{ mode, currentLocale }: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap,
|
||||
) {
|
||||
const missingUnicodeEmoji = new Set<string>();
|
||||
const missingCustomEmoji = new Set<string>();
|
||||
|
||||
// Iterate over tokens and check if they are in the cache already.
|
||||
for (const token of tokens) {
|
||||
if (typeof token === 'string') {
|
||||
continue; // Skip plain strings.
|
||||
}
|
||||
|
||||
// If this is a custom emoji, check it separately.
|
||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||
const code = token.code;
|
||||
if (code in extraEmojis) {
|
||||
continue; // We don't care about extra emoji.
|
||||
}
|
||||
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
|
||||
if (!emojiState) {
|
||||
missingCustomEmoji.add(code);
|
||||
}
|
||||
// Otherwise this is a unicode emoji, so check it against all locales.
|
||||
} else if (shouldRenderImage(token, mode)) {
|
||||
const code = emojiToUnicodeHex(token.code);
|
||||
if (missingUnicodeEmoji.has(code)) {
|
||||
continue; // Already marked as missing.
|
||||
}
|
||||
const emojiState = emojiForLocale(code, currentLocale);
|
||||
if (!emojiState) {
|
||||
// If it's missing in one locale, we consider it missing for all.
|
||||
missingUnicodeEmoji.add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingUnicodeEmoji.size > 0) {
|
||||
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
|
||||
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
|
||||
const cache = cacheForLocale(currentLocale);
|
||||
for (const emoji of emojis) {
|
||||
cache.set(emoji.hexcode, {
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
data: emoji,
|
||||
code: emoji.hexcode,
|
||||
});
|
||||
}
|
||||
localeCacheMap.set(currentLocale, cache);
|
||||
}
|
||||
|
||||
if (missingCustomEmoji.size > 0) {
|
||||
const missingEmojis = Array.from(missingCustomEmoji).toSorted();
|
||||
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
|
||||
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
|
||||
for (const emoji of emojis) {
|
||||
cache.set(emoji.shortcode, {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
data: emoji,
|
||||
code: emoji.shortcode,
|
||||
});
|
||||
}
|
||||
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRenderImage(token: EmojiState, mode: EmojiMode): boolean {
|
||||
if (token.type === EMOJI_TYPE_UNICODE) {
|
||||
export function shouldRenderImage(state: EmojiState, mode: EmojiMode): boolean {
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
// If the mode is native or native with flags for non-flag emoji
|
||||
// we can just append the text node directly.
|
||||
if (
|
||||
mode === EMOJI_MODE_NATIVE ||
|
||||
(mode === EMOJI_MODE_NATIVE_WITH_FLAGS &&
|
||||
!stringHasUnicodeFlags(token.code))
|
||||
!stringHasUnicodeFlags(state.code))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@ -438,52 +184,3 @@ export function shouldRenderImage(token: EmojiState, mode: EmojiMode): boolean {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
|
||||
const image = document.createElement('img');
|
||||
image.draggable = false;
|
||||
image.classList.add('emojione');
|
||||
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
image.alt = state.data.unicode;
|
||||
image.title = state.data.label;
|
||||
image.src = unicodeHexToUrl(state.data.hexcode, appState.darkTheme);
|
||||
} else {
|
||||
// Custom emoji
|
||||
const shortCode = `:${state.data.shortcode}:`;
|
||||
image.classList.add('custom-emoji');
|
||||
image.alt = shortCode;
|
||||
image.title = shortCode;
|
||||
image.src = autoPlayGif ? state.data.url : state.data.static_url;
|
||||
image.dataset.original = state.data.url;
|
||||
image.dataset.static = state.data.static_url;
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
function renderedToHTML(renderedArray: EmojifiedTextArray): DocumentFragment;
|
||||
function renderedToHTML<ParentType extends ParentNode>(
|
||||
renderedArray: EmojifiedTextArray,
|
||||
parent: ParentType,
|
||||
): ParentType;
|
||||
function renderedToHTML(
|
||||
renderedArray: EmojifiedTextArray,
|
||||
parent: ParentNode | null = null,
|
||||
) {
|
||||
const fragment = parent ?? document.createDocumentFragment();
|
||||
for (const fragmentItem of renderedArray) {
|
||||
if (typeof fragmentItem === 'string') {
|
||||
fragment.appendChild(document.createTextNode(fragmentItem));
|
||||
} else if (fragmentItem instanceof HTMLImageElement) {
|
||||
fragment.appendChild(fragmentItem);
|
||||
}
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
// Testing helpers
|
||||
export const testCacheClear = () => {
|
||||
cacheClear();
|
||||
localeCacheMap.clear();
|
||||
};
|
||||
|
||||
@ -4,7 +4,6 @@ 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 { LimitedCache } from '@/flavours/glitch/utils/cache';
|
||||
|
||||
import type {
|
||||
EMOJI_MODE_NATIVE,
|
||||
@ -48,12 +47,11 @@ export interface EmojiStateCustom {
|
||||
data?: CustomEmojiRenderFields;
|
||||
}
|
||||
export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
|
||||
|
||||
export type EmojiLoadedState =
|
||||
| Required<EmojiStateUnicode>
|
||||
| Required<EmojiStateCustom>;
|
||||
|
||||
export type EmojiStateMap = LimitedCache<string, EmojiState>;
|
||||
|
||||
export type CustomEmojiMapArg =
|
||||
| ExtraCustomEmojiMap
|
||||
| ImmutableList<CustomEmoji>
|
||||
@ -64,9 +62,3 @@ export type ExtraCustomEmojiMap = Record<
|
||||
string,
|
||||
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
|
||||
>;
|
||||
|
||||
export interface TwemojiBorderInfo {
|
||||
hexCode: string;
|
||||
hasLightBorder: boolean;
|
||||
hasDarkBorder: boolean;
|
||||
}
|
||||
|
||||
@ -1,35 +1,31 @@
|
||||
import {
|
||||
stringHasAnyEmoji,
|
||||
stringHasCustomEmoji,
|
||||
stringHasUnicodeEmoji,
|
||||
stringHasUnicodeFlags,
|
||||
} from './utils';
|
||||
import { isCustomEmoji, isUnicodeEmoji, stringHasUnicodeFlags } from './utils';
|
||||
|
||||
describe('stringHasUnicodeEmoji', () => {
|
||||
describe('isUnicodeEmoji', () => {
|
||||
test.concurrent.for([
|
||||
['only text', false],
|
||||
['text with non-emoji symbols ™©', false],
|
||||
['text with emoji 😀', true],
|
||||
['multiple emojis 😀😃😄', true],
|
||||
['emoji with skin tone 👍🏽', true],
|
||||
['emoji with ZWJ 👩❤️👨', true],
|
||||
['emoji with variation selector ✊️', true],
|
||||
['emoji with keycap 1️⃣', true],
|
||||
['emoji with flags 🇺🇸', true],
|
||||
['emoji with regional indicators 🇦🇺', true],
|
||||
['emoji with gender 👩⚕️', true],
|
||||
['emoji with family 👨👩👧👦', true],
|
||||
['emoji with zero width joiner 👩🔬', true],
|
||||
['emoji with non-BMP codepoint 🧑🚀', true],
|
||||
['emoji with combining marks 👨👩👧👦', true],
|
||||
['emoji with enclosing keycap #️⃣', true],
|
||||
['emoji with no visible glyph \u200D', false],
|
||||
] as const)(
|
||||
'stringHasUnicodeEmoji has emojis in "%s": %o',
|
||||
([text, expected], { expect }) => {
|
||||
expect(stringHasUnicodeEmoji(text)).toBe(expected);
|
||||
},
|
||||
);
|
||||
['😊', true],
|
||||
['🇿🇼', true],
|
||||
['🏴☠️', true],
|
||||
['🏳️🌈', true],
|
||||
['foo', false],
|
||||
[':smile:', false],
|
||||
['😊foo', false],
|
||||
] as const)('isUnicodeEmoji("%s") is %o', ([input, expected], { expect }) => {
|
||||
expect(isUnicodeEmoji(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCustomEmoji', () => {
|
||||
test.concurrent.for([
|
||||
[':smile:', true],
|
||||
[':smile_123:', true],
|
||||
[':SMILE:', true],
|
||||
['😊', false],
|
||||
['foo', false],
|
||||
[':smile', false],
|
||||
['smile:', false],
|
||||
] as const)('isCustomEmoji("%s") is %o', ([input, expected], { expect }) => {
|
||||
expect(isCustomEmoji(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringHasUnicodeFlags', () => {
|
||||
@ -51,27 +47,3 @@ describe('stringHasUnicodeFlags', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('stringHasCustomEmoji', () => {
|
||||
test('string with custom emoji returns true', () => {
|
||||
expect(stringHasCustomEmoji(':custom: :test:')).toBeTruthy();
|
||||
});
|
||||
test('string without custom emoji returns false', () => {
|
||||
expect(stringHasCustomEmoji('🏳️🌈 :🏳️🌈: text ™')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringHasAnyEmoji', () => {
|
||||
test('string without any emoji or characters', () => {
|
||||
expect(stringHasAnyEmoji('normal text. 12356?!')).toBeFalsy();
|
||||
});
|
||||
test('string with non-emoji characters', () => {
|
||||
expect(stringHasAnyEmoji('™©')).toBeFalsy();
|
||||
});
|
||||
test('has unicode emoji', () => {
|
||||
expect(stringHasAnyEmoji('🏳️🌈🔥🇸🇹 👩🔬')).toBeTruthy();
|
||||
});
|
||||
test('has custom emoji', () => {
|
||||
expect(stringHasAnyEmoji(':test: :custom:')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,10 +6,6 @@ export function emojiLogger(segment: string) {
|
||||
return debug(`emojis:${segment}`);
|
||||
}
|
||||
|
||||
export function stringHasUnicodeEmoji(input: string): boolean {
|
||||
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
|
||||
}
|
||||
|
||||
export function isUnicodeEmoji(input: string): boolean {
|
||||
return (
|
||||
input.length > 0 &&
|
||||
@ -34,19 +30,13 @@ export function stringHasUnicodeFlags(input: string): boolean {
|
||||
|
||||
// Constant as this is supported by all browsers.
|
||||
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
||||
// Use the polyfill regex or the Unicode property escapes if supported.
|
||||
const EMOJI_REGEX = emojiRegexPolyfill?.source ?? '\\p{RGI_Emoji}';
|
||||
|
||||
export function isCustomEmoji(input: string): boolean {
|
||||
return new RegExp(`^${CUSTOM_EMOJI_REGEX.source}$`, 'i').test(input);
|
||||
}
|
||||
|
||||
export function stringHasCustomEmoji(input: string) {
|
||||
return CUSTOM_EMOJI_REGEX.test(input);
|
||||
}
|
||||
|
||||
export function stringHasAnyEmoji(input: string) {
|
||||
return stringHasUnicodeEmoji(input) || stringHasCustomEmoji(input);
|
||||
}
|
||||
|
||||
export function anyEmojiRegex() {
|
||||
return new RegExp(
|
||||
`${EMOJI_REGEX}|${CUSTOM_EMOJI_REGEX.source}`,
|
||||
@ -64,5 +54,3 @@ function supportedFlags(flags = '') {
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const EMOJI_REGEX = emojiRegexPolyfill?.source ?? '\\p{RGI_Emoji}';
|
||||
|
||||
@ -16,15 +16,21 @@ import { expandHashtagTimeline, clearTimeline } from 'flavours/glitch/actions/ti
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||
import { remoteTopicFeedAccess, me } from 'flavours/glitch/initial_state';
|
||||
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
|
||||
import { HashtagHeader } from './components/hashtag_header';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
|
||||
});
|
||||
const mapStateToProps = (state, props) => {
|
||||
const local = props.params.local || (!me && remoteTopicFeedAccess !== 'public');
|
||||
|
||||
return ({
|
||||
local,
|
||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${local ? ':local' : ''}`, 'unread']) > 0,
|
||||
});
|
||||
};
|
||||
|
||||
class HashtagTimeline extends PureComponent {
|
||||
disconnects = [];
|
||||
@ -113,16 +119,16 @@ class HashtagTimeline extends PureComponent {
|
||||
}
|
||||
|
||||
_unload () {
|
||||
const { dispatch } = this.props;
|
||||
const { id, local } = this.props.params;
|
||||
const { dispatch, local } = this.props;
|
||||
const { id } = this.props.params;
|
||||
|
||||
this._unsubscribe();
|
||||
dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
|
||||
}
|
||||
|
||||
_load() {
|
||||
const { dispatch } = this.props;
|
||||
const { id, tags, local } = this.props.params;
|
||||
const { dispatch, local } = this.props;
|
||||
const { id, tags } = this.props.params;
|
||||
|
||||
this._subscribe(dispatch, id, tags, local);
|
||||
dispatch(expandHashtagTimeline(id, { tags, local }));
|
||||
@ -133,8 +139,8 @@ class HashtagTimeline extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { params } = this.props;
|
||||
const { id, tags, local } = prevProps.params;
|
||||
const { params, local } = this.props;
|
||||
const { id, tags } = prevProps.params;
|
||||
|
||||
if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
|
||||
this._unload();
|
||||
@ -151,15 +157,15 @@ class HashtagTimeline extends PureComponent {
|
||||
};
|
||||
|
||||
handleLoadMore = maxId => {
|
||||
const { dispatch, params } = this.props;
|
||||
const { id, tags, local } = params;
|
||||
const { dispatch, params, local } = this.props;
|
||||
const { id, tags } = params;
|
||||
|
||||
dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { hasUnread, columnId, multiColumn } = this.props;
|
||||
const { id, local } = this.props.params;
|
||||
const { hasUnread, columnId, multiColumn, local } = this.props;
|
||||
const { id } = this.props.params;
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
|
||||
@ -36,6 +36,8 @@ interface InitialStateMeta {
|
||||
streaming_api_base_url: string;
|
||||
local_live_feed_access: 'public' | 'authenticated';
|
||||
remote_live_feed_access: 'public' | 'authenticated';
|
||||
local_topic_feed_access: 'public' | 'authenticated';
|
||||
remote_topic_feed_access: 'public' | 'authenticated';
|
||||
title: string;
|
||||
show_trends: boolean;
|
||||
trends_as_landing_page: boolean;
|
||||
@ -141,6 +143,8 @@ export const singleUserMode = getMeta('single_user_mode');
|
||||
export const source_url = getMeta('source_url');
|
||||
export const localLiveFeedAccess = getMeta('local_live_feed_access');
|
||||
export const remoteLiveFeedAccess = getMeta('remote_live_feed_access');
|
||||
export const localTopicFeedAccess = getMeta('local_topic_feed_access');
|
||||
export const remoteTopicFeedAccess = getMeta('remote_topic_feed_access');
|
||||
export const title = getMeta('title');
|
||||
export const trendsAsLanding = getMeta('trends_as_landing_page');
|
||||
export const useBlurhash = getMeta('use_blurhash');
|
||||
|
||||
@ -1587,7 +1587,7 @@
|
||||
& > .status__content,
|
||||
& > .status__action-bar,
|
||||
& > .media-gallery,
|
||||
& > .video-player,
|
||||
& > div > .video-player,
|
||||
& > .audio-player,
|
||||
& > .attachment-list,
|
||||
& > .picture-in-picture-placeholder,
|
||||
|
||||
@ -21,6 +21,10 @@ import { importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
quoteErrorEdit: {
|
||||
id: 'quote_error.edit',
|
||||
defaultMessage: 'Quotes cannot be added when editing a post.',
|
||||
},
|
||||
quoteErrorUpload: {
|
||||
id: 'quote_error.upload',
|
||||
defaultMessage: 'Quoting is not allowed with media attachments.',
|
||||
@ -124,7 +128,9 @@ export const quoteComposeByStatus = createAppThunk(
|
||||
false,
|
||||
);
|
||||
|
||||
if (composeState.get('poll')) {
|
||||
if (composeState.get('id')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorEdit }));
|
||||
} else if (composeState.get('poll')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||
} else if (
|
||||
composeState.get('is_uploading') ||
|
||||
@ -184,7 +190,8 @@ export const pasteLinkCompose = createDataLoadingThunk(
|
||||
composeState.get('quoted_status_id') ||
|
||||
composeState.get('is_submitting') ||
|
||||
composeState.get('poll') ||
|
||||
composeState.get('is_uploading')
|
||||
composeState.get('is_uploading') ||
|
||||
composeState.get('id')
|
||||
)
|
||||
return;
|
||||
|
||||
|
||||
56
app/javascript/mastodon/components/emoji/emoji.stories.tsx
Normal file
56
app/javascript/mastodon/components/emoji/emoji.stories.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
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 };
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Emoji',
|
||||
component: Emoji,
|
||||
args: {
|
||||
code: '🖤',
|
||||
state: 'auto',
|
||||
},
|
||||
argTypes: {
|
||||
code: {
|
||||
name: 'Emoji',
|
||||
},
|
||||
state: {
|
||||
control: {
|
||||
type: 'select',
|
||||
labels: {
|
||||
auto: 'Auto',
|
||||
native: 'Native',
|
||||
twemoji: 'Twemoji',
|
||||
},
|
||||
},
|
||||
options: ['auto', 'native', 'twemoji'],
|
||||
name: 'Emoji Style',
|
||||
mapping: {
|
||||
auto: { meta: { emoji_style: 'auto' } },
|
||||
native: { meta: { emoji_style: 'native' } },
|
||||
twemoji: { meta: { emoji_style: 'twemoji' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
render(args) {
|
||||
void importCustomEmojiData();
|
||||
return <Emoji {...args} />;
|
||||
},
|
||||
} satisfies Meta<EmojiProps>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const CustomEmoji: Story = {
|
||||
args: {
|
||||
code: ':custom:',
|
||||
},
|
||||
};
|
||||
@ -14,7 +14,7 @@ import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
||||
import { textToEmojis } from './index';
|
||||
|
||||
interface EmojiHTMLProps {
|
||||
export interface EmojiHTMLProps {
|
||||
htmlString: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
className?: string;
|
||||
|
||||
@ -2,7 +2,7 @@ import type { FC } from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { EMOJI_TYPE_CUSTOM } from '@/mastodon/features/emoji/constants';
|
||||
import { useEmojiAppState } from '@/mastodon/features/emoji/hooks';
|
||||
import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
|
||||
import { unicodeHexToUrl } from '@/mastodon/features/emoji/normalize';
|
||||
import {
|
||||
isStateLoaded,
|
||||
|
||||
@ -7,23 +7,49 @@ const meta = {
|
||||
title: 'Components/HTMLBlock',
|
||||
component: HTMLBlock,
|
||||
args: {
|
||||
contents:
|
||||
'<p>Hello, world!</p>\n<p><a href="#">A link</a></p>\n<p>This should be filtered out: <button>Bye!</button></p>',
|
||||
htmlString: `<p>Hello, world!</p>
|
||||
<p><a href="#">A link</a></p>
|
||||
<p>This should be filtered out: <button>Bye!</button></p>
|
||||
<p>This also has emoji: 🖤</p>`,
|
||||
},
|
||||
argTypes: {
|
||||
extraEmojis: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
onElement: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
onAttribute: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
// Just for visual clarity in Storybook.
|
||||
<div
|
||||
<HTMLBlock
|
||||
{...args}
|
||||
style={{
|
||||
border: '1px solid black',
|
||||
padding: '1rem',
|
||||
minWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<HTMLBlock {...args} />
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Force Twemoji to demonstrate emoji rendering.
|
||||
parameters: {
|
||||
state: {
|
||||
meta: {
|
||||
emoji_style: 'twemoji',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof HTMLBlock>;
|
||||
|
||||
export default meta;
|
||||
|
||||
@ -1,50 +1,30 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
|
||||
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
|
||||
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||
import type { OnElementHandler } from '@/mastodon/utils/html';
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import { htmlStringToComponents } from '../../utils/html';
|
||||
import type { EmojiHTMLProps } from '../emoji/html';
|
||||
import { ModernEmojiHTML } from '../emoji/html';
|
||||
import { useElementHandledLink } from '../status/handled_link';
|
||||
|
||||
// Use a module-level cache to avoid re-rendering the same HTML multiple times.
|
||||
const cache = createLimitedCache<ReactNode>({ maxSize: 1000 });
|
||||
|
||||
interface HTMLBlockProps {
|
||||
contents: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
}
|
||||
|
||||
export const HTMLBlock: FC<HTMLBlockProps> = ({
|
||||
contents: raw,
|
||||
extraEmojis,
|
||||
}) => {
|
||||
const customEmojis = useMemo(
|
||||
() => cleanExtraEmojis(extraEmojis),
|
||||
[extraEmojis],
|
||||
);
|
||||
const contents = useMemo(() => {
|
||||
const key = JSON.stringify({ raw, customEmojis });
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
const rendered = htmlStringToComponents(raw, {
|
||||
onText,
|
||||
extraArgs: { customEmojis },
|
||||
export const HTMLBlock = polymorphicForwardRef<
|
||||
'div',
|
||||
EmojiHTMLProps & Parameters<typeof useElementHandledLink>[0]
|
||||
>(
|
||||
({
|
||||
onElement: onParentElement,
|
||||
hrefToMention,
|
||||
hashtagAccountId,
|
||||
...props
|
||||
}) => {
|
||||
const { onElement: onLinkElement } = useElementHandledLink({
|
||||
hrefToMention,
|
||||
hashtagAccountId,
|
||||
});
|
||||
|
||||
cache.set(key, rendered);
|
||||
return rendered;
|
||||
}, [raw, customEmojis]);
|
||||
|
||||
return contents;
|
||||
};
|
||||
|
||||
function onText(
|
||||
text: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Doesn't do anything, just showing how typing would work.
|
||||
{ customEmojis }: { customEmojis: CustomEmojiMapArg | null },
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
const onElement: OnElementHandler = useCallback(
|
||||
(...args) => onParentElement?.(...args) ?? onLinkElement(...args),
|
||||
[onLinkElement, onParentElement],
|
||||
);
|
||||
return <ModernEmojiHTML {...props} onElement={onElement} />;
|
||||
},
|
||||
);
|
||||
|
||||
@ -7,7 +7,7 @@ import { assetHost } from 'mastodon/utils/config';
|
||||
|
||||
import { EMOJI_MODE_NATIVE } from './constants';
|
||||
import EmojiData from './emoji_data.json';
|
||||
import { useEmojiAppState } from './hooks';
|
||||
import { useEmojiAppState } from './mode';
|
||||
|
||||
const backgroundImageFnDefault = () => `${assetHost}/emoji/sheet_15_1.png`;
|
||||
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import { toSupportedLocale } from './locale';
|
||||
import { determineEmojiMode } from './mode';
|
||||
import { cleanExtraEmojis } from './normalize';
|
||||
import { emojifyElement, emojifyText } from './render';
|
||||
import type { CustomEmojiMapArg, EmojiAppState } from './types';
|
||||
import { stringHasAnyEmoji } from './utils';
|
||||
|
||||
interface UseEmojifyOptions {
|
||||
text: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
deep?: boolean;
|
||||
}
|
||||
|
||||
export function useEmojify({
|
||||
text,
|
||||
extraEmojis,
|
||||
deep = true,
|
||||
}: UseEmojifyOptions) {
|
||||
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||
|
||||
const appState = useEmojiAppState();
|
||||
const extra = useMemo(() => cleanExtraEmojis(extraEmojis), [extraEmojis]);
|
||||
|
||||
const emojify = useCallback(
|
||||
async (input: string) => {
|
||||
let result: string | null = null;
|
||||
if (deep) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = input;
|
||||
if (await emojifyElement(wrapper, appState, extra ?? {})) {
|
||||
result = wrapper.innerHTML;
|
||||
}
|
||||
} else {
|
||||
result = await emojifyText(text, appState, extra ?? {});
|
||||
}
|
||||
if (result) {
|
||||
setEmojifiedText(result);
|
||||
} else {
|
||||
setEmojifiedText(input);
|
||||
}
|
||||
},
|
||||
[appState, deep, extra, text],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
||||
void emojify(text);
|
||||
} else {
|
||||
// If no emoji or we don't want to render, fall back.
|
||||
setEmojifiedText(text);
|
||||
}
|
||||
}, [emojify, text]);
|
||||
|
||||
return emojifiedText;
|
||||
}
|
||||
|
||||
const modeSelector = createAppSelector(
|
||||
[(state) => state.meta.get('emoji_style') as string],
|
||||
(emoji_style) => determineEmojiMode(emoji_style),
|
||||
);
|
||||
|
||||
export function useEmojiAppState(): EmojiAppState {
|
||||
const locale = useAppSelector((state) =>
|
||||
toSupportedLocale(state.meta.get('locale') as string),
|
||||
);
|
||||
const mode = useAppSelector(modeSelector);
|
||||
|
||||
return {
|
||||
currentLocale: locale,
|
||||
locales: [locale],
|
||||
mode,
|
||||
darkTheme: document.body.classList.contains('theme-default'),
|
||||
};
|
||||
}
|
||||
@ -10,6 +10,8 @@ let worker: Worker | null = null;
|
||||
|
||||
const log = emojiLogger('index');
|
||||
|
||||
const WORKER_TIMEOUT = 1_000; // 1 second
|
||||
|
||||
export function initializeEmoji() {
|
||||
log('initializing emojis');
|
||||
if (!worker && 'Worker' in window) {
|
||||
@ -29,7 +31,7 @@ export function initializeEmoji() {
|
||||
log('worker is not ready after timeout');
|
||||
worker = null;
|
||||
void fallbackLoad();
|
||||
}, 500);
|
||||
}, WORKER_TIMEOUT);
|
||||
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
const { data: message } = event;
|
||||
if (message === 'ready') {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// Credit to Nolan Lawson for the original implementation.
|
||||
// See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/utils/testColorEmojiSupported.js
|
||||
|
||||
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||
import { isDevelopment } from '@/mastodon/utils/environment';
|
||||
|
||||
import {
|
||||
@ -8,7 +9,27 @@ import {
|
||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
EMOJI_MODE_TWEMOJI,
|
||||
} from './constants';
|
||||
import type { EmojiMode } from './types';
|
||||
import { toSupportedLocale } from './locale';
|
||||
import type { EmojiAppState, EmojiMode } from './types';
|
||||
|
||||
const modeSelector = createAppSelector(
|
||||
[(state) => state.meta.get('emoji_style') as string],
|
||||
(emoji_style) => determineEmojiMode(emoji_style),
|
||||
);
|
||||
|
||||
export function useEmojiAppState(): EmojiAppState {
|
||||
const locale = useAppSelector((state) =>
|
||||
toSupportedLocale(state.meta.get('locale') as string),
|
||||
);
|
||||
const mode = useAppSelector(modeSelector);
|
||||
|
||||
return {
|
||||
currentLocale: locale,
|
||||
locales: [locale],
|
||||
mode,
|
||||
darkTheme: document.body.classList.contains('theme-default'),
|
||||
};
|
||||
}
|
||||
|
||||
type Feature = Uint8ClampedArray;
|
||||
|
||||
|
||||
@ -1,101 +1,12 @@
|
||||
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||
|
||||
import { EMOJI_MODE_TWEMOJI } from './constants';
|
||||
import * as db from './database';
|
||||
import * as loader from './loader';
|
||||
import {
|
||||
emojifyElement,
|
||||
emojifyText,
|
||||
testCacheClear,
|
||||
loadEmojiDataToState,
|
||||
stringToEmojiState,
|
||||
tokenizeText,
|
||||
} from './render';
|
||||
import type { EmojiAppState } from './types';
|
||||
|
||||
function mockDatabase() {
|
||||
return {
|
||||
searchCustomEmojisByShortcodes: vi
|
||||
.spyOn(db, 'searchCustomEmojisByShortcodes')
|
||||
.mockResolvedValue([customEmojiFactory()]),
|
||||
searchEmojisByHexcodes: vi
|
||||
.spyOn(db, 'searchEmojisByHexcodes')
|
||||
.mockResolvedValue([
|
||||
unicodeEmojiFactory({
|
||||
hexcode: '1F60A',
|
||||
label: 'smiling face with smiling eyes',
|
||||
unicode: '😊',
|
||||
}),
|
||||
unicodeEmojiFactory({
|
||||
hexcode: '1F1EA-1F1FA',
|
||||
label: 'flag-eu',
|
||||
unicode: '🇪🇺',
|
||||
}),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const expectedSmileImage =
|
||||
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
||||
const expectedFlagImage =
|
||||
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
||||
|
||||
function testAppState(state: Partial<EmojiAppState> = {}) {
|
||||
return {
|
||||
locales: ['en'],
|
||||
mode: EMOJI_MODE_TWEMOJI,
|
||||
currentLocale: 'en',
|
||||
darkTheme: false,
|
||||
...state,
|
||||
} satisfies EmojiAppState;
|
||||
}
|
||||
|
||||
describe('emojifyElement', () => {
|
||||
function testElement(text = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>') {
|
||||
const testElement = document.createElement('div');
|
||||
testElement.innerHTML = text;
|
||||
return testElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
testCacheClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('caches element rendering results', async () => {
|
||||
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||
mockDatabase();
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
await emojifyElement(testElement(), testAppState());
|
||||
expect(searchEmojisByHexcodes).toHaveBeenCalledExactlyOnceWith(
|
||||
['1F1EA-1F1FA', '1F60A'],
|
||||
'en',
|
||||
);
|
||||
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
|
||||
':custom:',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns null when no emoji are found', async () => {
|
||||
mockDatabase();
|
||||
const actual = await emojifyElement(
|
||||
testElement('<p>here is just text :)</p>'),
|
||||
testAppState(),
|
||||
);
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emojifyText', () => {
|
||||
test('returns original input when no emoji are in string', async () => {
|
||||
const actual = await emojifyText('nothing here', testAppState());
|
||||
expect(actual).toBe('nothing here');
|
||||
});
|
||||
|
||||
test('renders Unicode emojis to twemojis', async () => {
|
||||
mockDatabase();
|
||||
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
|
||||
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenizeText', () => {
|
||||
test('returns an array of text to be a single token', () => {
|
||||
@ -162,3 +73,106 @@ describe('tokenizeText', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringToEmojiState', () => {
|
||||
test('returns unicode emoji state for valid unicode emoji', () => {
|
||||
expect(stringToEmojiState('😊')).toEqual({
|
||||
type: 'unicode',
|
||||
code: '1F60A',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns custom emoji state for valid custom emoji', () => {
|
||||
expect(stringToEmojiState(':smile:')).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns custom emoji state with data when provided', () => {
|
||||
const customEmoji = {
|
||||
smile: customEmojiFactory({
|
||||
shortcode: 'smile',
|
||||
url: 'https://example.com/smile.png',
|
||||
static_url: 'https://example.com/smile_static.png',
|
||||
}),
|
||||
};
|
||||
expect(stringToEmojiState(':smile:', customEmoji)).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: customEmoji.smile,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for invalid emoji strings', () => {
|
||||
expect(stringToEmojiState('notanemoji')).toBeNull();
|
||||
expect(stringToEmojiState(':invalid-emoji:')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadEmojiDataToState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('loads unicode data into state', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadEmojiByHexcode')
|
||||
.mockResolvedValue(unicodeEmojiFactory());
|
||||
const unicodeState = { type: 'unicode', code: '1F60A' } as const;
|
||||
const result = await loadEmojiDataToState(unicodeState, 'en');
|
||||
expect(dbCall).toHaveBeenCalledWith('1F60A', 'en');
|
||||
expect(result).toEqual({
|
||||
type: 'unicode',
|
||||
code: '1F60A',
|
||||
data: unicodeEmojiFactory(),
|
||||
});
|
||||
});
|
||||
|
||||
test('loads custom emoji data into state', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadCustomEmojiByShortcode')
|
||||
.mockResolvedValueOnce(customEmojiFactory());
|
||||
const customState = { type: 'custom', code: 'smile' } as const;
|
||||
const result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(dbCall).toHaveBeenCalledWith('smile');
|
||||
expect(result).toEqual({
|
||||
type: 'custom',
|
||||
code: 'smile',
|
||||
data: customEmojiFactory(),
|
||||
});
|
||||
});
|
||||
|
||||
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 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 result = await loadEmojiDataToState(customState, 'en');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('retries loading emoji data once if initial load fails', async () => {
|
||||
const dbCall = vi
|
||||
.spyOn(db, 'loadEmojiByHexcode')
|
||||
.mockRejectedValue(new db.LocaleNotLoadedError('en'));
|
||||
vi.spyOn(loader, 'importEmojiData').mockResolvedValueOnce();
|
||||
const consoleCall = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementationOnce(() => null);
|
||||
|
||||
const unicodeState = { type: 'unicode', code: '1F60A' } as const;
|
||||
const result = await loadEmojiDataToState(unicodeState, 'en');
|
||||
|
||||
expect(dbCall).toHaveBeenCalledTimes(2);
|
||||
expect(loader.importEmojiData).toHaveBeenCalledWith('en');
|
||||
expect(consoleCall).toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,3 @@
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||
import * as perf from '@/mastodon/utils/performance';
|
||||
|
||||
import {
|
||||
EMOJI_MODE_NATIVE,
|
||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||
@ -12,33 +8,69 @@ import {
|
||||
loadCustomEmojiByShortcode,
|
||||
loadEmojiByHexcode,
|
||||
LocaleNotLoadedError,
|
||||
searchCustomEmojisByShortcodes,
|
||||
searchEmojisByHexcodes,
|
||||
} from './database';
|
||||
import { importEmojiData } from './loader';
|
||||
import { emojiToUnicodeHex, unicodeHexToUrl } from './normalize';
|
||||
import { emojiToUnicodeHex } from './normalize';
|
||||
import type {
|
||||
EmojiAppState,
|
||||
EmojiLoadedState,
|
||||
EmojiMode,
|
||||
EmojiState,
|
||||
EmojiStateCustom,
|
||||
EmojiStateMap,
|
||||
EmojiStateUnicode,
|
||||
ExtraCustomEmojiMap,
|
||||
LocaleOrCustom,
|
||||
} from './types';
|
||||
import {
|
||||
anyEmojiRegex,
|
||||
emojiLogger,
|
||||
isCustomEmoji,
|
||||
isUnicodeEmoji,
|
||||
stringHasAnyEmoji,
|
||||
stringHasUnicodeFlags,
|
||||
} from './utils';
|
||||
|
||||
const log = emojiLogger('render');
|
||||
|
||||
type TokenizedText = (string | EmojiState)[];
|
||||
|
||||
/**
|
||||
* Tokenizes text into strings and emoji states.
|
||||
* @param text Text to tokenize.
|
||||
* @returns Array of strings and emoji states.
|
||||
*/
|
||||
export function tokenizeText(text: string): TokenizedText {
|
||||
if (!text.trim()) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const tokens = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(anyEmojiRegex())) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const code = match[0];
|
||||
|
||||
if (code.startsWith(':') && code.endsWith(':')) {
|
||||
// Custom emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code,
|
||||
} satisfies EmojiStateCustom);
|
||||
} else {
|
||||
// Unicode emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
code: code,
|
||||
} satisfies EmojiStateUnicode);
|
||||
}
|
||||
lastIndex = match.index + code.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push(text.slice(lastIndex));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses emoji string to extract emoji state.
|
||||
* @param code Hex code or custom shortcode.
|
||||
@ -132,305 +164,19 @@ export function isStateLoaded(state: EmojiState): state is EmojiLoadedState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
||||
* Determines if the given token should be rendered as an image based on the emoji mode.
|
||||
* @param state Emoji state to parse.
|
||||
* @param mode Rendering mode.
|
||||
* @returns Whether to render as an image.
|
||||
*/
|
||||
export async function emojifyElement<Element extends HTMLElement>(
|
||||
element: Element,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<Element | null> {
|
||||
const cacheKey = createCacheKey(element, appState, extraEmojis);
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
log('Cache hit on %s', element.outerHTML);
|
||||
if (cached === null) {
|
||||
return null;
|
||||
}
|
||||
element.innerHTML = cached;
|
||||
return element;
|
||||
}
|
||||
if (!stringHasAnyEmoji(element.innerHTML)) {
|
||||
updateCache(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
perf.start('emojifyElement()');
|
||||
const queue: (HTMLElement | Text)[] = [element];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (
|
||||
!current ||
|
||||
current instanceof HTMLScriptElement ||
|
||||
current instanceof HTMLStyleElement
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
current.textContent &&
|
||||
(current instanceof Text || !current.hasChildNodes())
|
||||
) {
|
||||
const renderedContent = await textToElementArray(
|
||||
current.textContent,
|
||||
appState,
|
||||
extraEmojis,
|
||||
);
|
||||
if (renderedContent) {
|
||||
if (!(current instanceof Text)) {
|
||||
current.textContent = null; // Clear the text content if it's not a Text node.
|
||||
}
|
||||
current.replaceWith(renderedToHTML(renderedContent));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of current.childNodes) {
|
||||
if (child instanceof HTMLElement || child instanceof Text) {
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateCache(cacheKey, element.innerHTML);
|
||||
perf.stop('emojifyElement()');
|
||||
return element;
|
||||
}
|
||||
|
||||
export async function emojifyText(
|
||||
text: string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<string | null> {
|
||||
const cacheKey = createCacheKey(text, appState, extraEmojis);
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
log('Cache hit on %s', text);
|
||||
return cached ?? text;
|
||||
}
|
||||
if (!stringHasAnyEmoji(text)) {
|
||||
updateCache(cacheKey, null);
|
||||
return text;
|
||||
}
|
||||
const eleArray = await textToElementArray(text, appState, extraEmojis);
|
||||
if (!eleArray) {
|
||||
updateCache(cacheKey, null);
|
||||
return text;
|
||||
}
|
||||
const rendered = renderedToHTML(eleArray, document.createElement('div'));
|
||||
updateCache(cacheKey, rendered.innerHTML);
|
||||
return rendered.innerHTML;
|
||||
}
|
||||
|
||||
// Private functions
|
||||
|
||||
const {
|
||||
set: updateCache,
|
||||
get: getCached,
|
||||
clear: cacheClear,
|
||||
} = createLimitedCache<string | null>({ log: log.extend('cache') });
|
||||
|
||||
function createCacheKey(
|
||||
input: HTMLElement | string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap,
|
||||
) {
|
||||
return JSON.stringify([
|
||||
input instanceof HTMLElement ? input.outerHTML : input,
|
||||
appState,
|
||||
extraEmojis,
|
||||
]);
|
||||
}
|
||||
|
||||
type EmojifiedTextArray = (string | HTMLImageElement)[];
|
||||
|
||||
async function textToElementArray(
|
||||
text: string,
|
||||
appState: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap = {},
|
||||
): Promise<EmojifiedTextArray | null> {
|
||||
// Exit if no text to convert.
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = tokenizeText(text);
|
||||
|
||||
// If only one token and it's a string, exit early.
|
||||
if (tokens.length === 1 && typeof tokens[0] === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all emoji from the state map, loading any missing ones.
|
||||
await loadMissingEmojiIntoCache(tokens, appState, extraEmojis);
|
||||
|
||||
const renderedFragments: EmojifiedTextArray = [];
|
||||
for (const token of tokens) {
|
||||
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
|
||||
let state: EmojiState | undefined;
|
||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||
const extraEmojiData = extraEmojis[token.code];
|
||||
if (extraEmojiData) {
|
||||
state = {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
data: extraEmojiData,
|
||||
code: token.code,
|
||||
};
|
||||
} else {
|
||||
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
|
||||
}
|
||||
} else {
|
||||
state = emojiForLocale(
|
||||
emojiToUnicodeHex(token.code),
|
||||
appState.currentLocale,
|
||||
);
|
||||
}
|
||||
|
||||
// If the state is valid, create an image element. Otherwise, just append as text.
|
||||
if (state && typeof state !== 'string' && isStateLoaded(state)) {
|
||||
const image = stateToImage(state, appState);
|
||||
renderedFragments.push(image);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const text = typeof token === 'string' ? token : token.code;
|
||||
renderedFragments.push(text);
|
||||
}
|
||||
|
||||
return renderedFragments;
|
||||
}
|
||||
|
||||
type TokenizedText = (string | EmojiState)[];
|
||||
|
||||
export function tokenizeText(text: string): TokenizedText {
|
||||
if (!text.trim()) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const tokens = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(anyEmojiRegex())) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const code = match[0];
|
||||
|
||||
if (code.startsWith(':') && code.endsWith(':')) {
|
||||
// Custom emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
code,
|
||||
} satisfies EmojiStateCustom);
|
||||
} else {
|
||||
// Unicode emoji
|
||||
tokens.push({
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
code: code,
|
||||
} satisfies EmojiStateUnicode);
|
||||
}
|
||||
lastIndex = match.index + code.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push(text.slice(lastIndex));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
||||
[
|
||||
EMOJI_TYPE_CUSTOM,
|
||||
createLimitedCache<EmojiState>({ log: log.extend('custom') }),
|
||||
],
|
||||
]);
|
||||
|
||||
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
|
||||
return (
|
||||
localeCacheMap.get(locale) ??
|
||||
createLimitedCache<EmojiState>({ log: log.extend(locale) })
|
||||
);
|
||||
}
|
||||
|
||||
function emojiForLocale(
|
||||
code: string,
|
||||
locale: LocaleOrCustom,
|
||||
): EmojiState | undefined {
|
||||
const cache = cacheForLocale(locale);
|
||||
return cache.get(code);
|
||||
}
|
||||
|
||||
async function loadMissingEmojiIntoCache(
|
||||
tokens: TokenizedText,
|
||||
{ mode, currentLocale }: EmojiAppState,
|
||||
extraEmojis: ExtraCustomEmojiMap,
|
||||
) {
|
||||
const missingUnicodeEmoji = new Set<string>();
|
||||
const missingCustomEmoji = new Set<string>();
|
||||
|
||||
// Iterate over tokens and check if they are in the cache already.
|
||||
for (const token of tokens) {
|
||||
if (typeof token === 'string') {
|
||||
continue; // Skip plain strings.
|
||||
}
|
||||
|
||||
// If this is a custom emoji, check it separately.
|
||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||
const code = token.code;
|
||||
if (code in extraEmojis) {
|
||||
continue; // We don't care about extra emoji.
|
||||
}
|
||||
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
|
||||
if (!emojiState) {
|
||||
missingCustomEmoji.add(code);
|
||||
}
|
||||
// Otherwise this is a unicode emoji, so check it against all locales.
|
||||
} else if (shouldRenderImage(token, mode)) {
|
||||
const code = emojiToUnicodeHex(token.code);
|
||||
if (missingUnicodeEmoji.has(code)) {
|
||||
continue; // Already marked as missing.
|
||||
}
|
||||
const emojiState = emojiForLocale(code, currentLocale);
|
||||
if (!emojiState) {
|
||||
// If it's missing in one locale, we consider it missing for all.
|
||||
missingUnicodeEmoji.add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingUnicodeEmoji.size > 0) {
|
||||
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
|
||||
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
|
||||
const cache = cacheForLocale(currentLocale);
|
||||
for (const emoji of emojis) {
|
||||
cache.set(emoji.hexcode, {
|
||||
type: EMOJI_TYPE_UNICODE,
|
||||
data: emoji,
|
||||
code: emoji.hexcode,
|
||||
});
|
||||
}
|
||||
localeCacheMap.set(currentLocale, cache);
|
||||
}
|
||||
|
||||
if (missingCustomEmoji.size > 0) {
|
||||
const missingEmojis = Array.from(missingCustomEmoji).toSorted();
|
||||
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
|
||||
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
|
||||
for (const emoji of emojis) {
|
||||
cache.set(emoji.shortcode, {
|
||||
type: EMOJI_TYPE_CUSTOM,
|
||||
data: emoji,
|
||||
code: emoji.shortcode,
|
||||
});
|
||||
}
|
||||
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRenderImage(token: EmojiState, mode: EmojiMode): boolean {
|
||||
if (token.type === EMOJI_TYPE_UNICODE) {
|
||||
export function shouldRenderImage(state: EmojiState, mode: EmojiMode): boolean {
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
// If the mode is native or native with flags for non-flag emoji
|
||||
// we can just append the text node directly.
|
||||
if (
|
||||
mode === EMOJI_MODE_NATIVE ||
|
||||
(mode === EMOJI_MODE_NATIVE_WITH_FLAGS &&
|
||||
!stringHasUnicodeFlags(token.code))
|
||||
!stringHasUnicodeFlags(state.code))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@ -438,52 +184,3 @@ export function shouldRenderImage(token: EmojiState, mode: EmojiMode): boolean {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
|
||||
const image = document.createElement('img');
|
||||
image.draggable = false;
|
||||
image.classList.add('emojione');
|
||||
|
||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||
image.alt = state.data.unicode;
|
||||
image.title = state.data.label;
|
||||
image.src = unicodeHexToUrl(state.data.hexcode, appState.darkTheme);
|
||||
} else {
|
||||
// Custom emoji
|
||||
const shortCode = `:${state.data.shortcode}:`;
|
||||
image.classList.add('custom-emoji');
|
||||
image.alt = shortCode;
|
||||
image.title = shortCode;
|
||||
image.src = autoPlayGif ? state.data.url : state.data.static_url;
|
||||
image.dataset.original = state.data.url;
|
||||
image.dataset.static = state.data.static_url;
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
function renderedToHTML(renderedArray: EmojifiedTextArray): DocumentFragment;
|
||||
function renderedToHTML<ParentType extends ParentNode>(
|
||||
renderedArray: EmojifiedTextArray,
|
||||
parent: ParentType,
|
||||
): ParentType;
|
||||
function renderedToHTML(
|
||||
renderedArray: EmojifiedTextArray,
|
||||
parent: ParentNode | null = null,
|
||||
) {
|
||||
const fragment = parent ?? document.createDocumentFragment();
|
||||
for (const fragmentItem of renderedArray) {
|
||||
if (typeof fragmentItem === 'string') {
|
||||
fragment.appendChild(document.createTextNode(fragmentItem));
|
||||
} else if (fragmentItem instanceof HTMLImageElement) {
|
||||
fragment.appendChild(fragmentItem);
|
||||
}
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
// Testing helpers
|
||||
export const testCacheClear = () => {
|
||||
cacheClear();
|
||||
localeCacheMap.clear();
|
||||
};
|
||||
|
||||
@ -4,7 +4,6 @@ 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 { LimitedCache } from '@/mastodon/utils/cache';
|
||||
|
||||
import type {
|
||||
EMOJI_MODE_NATIVE,
|
||||
@ -48,12 +47,11 @@ export interface EmojiStateCustom {
|
||||
data?: CustomEmojiRenderFields;
|
||||
}
|
||||
export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
|
||||
|
||||
export type EmojiLoadedState =
|
||||
| Required<EmojiStateUnicode>
|
||||
| Required<EmojiStateCustom>;
|
||||
|
||||
export type EmojiStateMap = LimitedCache<string, EmojiState>;
|
||||
|
||||
export type CustomEmojiMapArg =
|
||||
| ExtraCustomEmojiMap
|
||||
| ImmutableList<CustomEmoji>
|
||||
@ -64,9 +62,3 @@ export type ExtraCustomEmojiMap = Record<
|
||||
string,
|
||||
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
|
||||
>;
|
||||
|
||||
export interface TwemojiBorderInfo {
|
||||
hexCode: string;
|
||||
hasLightBorder: boolean;
|
||||
hasDarkBorder: boolean;
|
||||
}
|
||||
|
||||
@ -1,35 +1,31 @@
|
||||
import {
|
||||
stringHasAnyEmoji,
|
||||
stringHasCustomEmoji,
|
||||
stringHasUnicodeEmoji,
|
||||
stringHasUnicodeFlags,
|
||||
} from './utils';
|
||||
import { isCustomEmoji, isUnicodeEmoji, stringHasUnicodeFlags } from './utils';
|
||||
|
||||
describe('stringHasUnicodeEmoji', () => {
|
||||
describe('isUnicodeEmoji', () => {
|
||||
test.concurrent.for([
|
||||
['only text', false],
|
||||
['text with non-emoji symbols ™©', false],
|
||||
['text with emoji 😀', true],
|
||||
['multiple emojis 😀😃😄', true],
|
||||
['emoji with skin tone 👍🏽', true],
|
||||
['emoji with ZWJ 👩❤️👨', true],
|
||||
['emoji with variation selector ✊️', true],
|
||||
['emoji with keycap 1️⃣', true],
|
||||
['emoji with flags 🇺🇸', true],
|
||||
['emoji with regional indicators 🇦🇺', true],
|
||||
['emoji with gender 👩⚕️', true],
|
||||
['emoji with family 👨👩👧👦', true],
|
||||
['emoji with zero width joiner 👩🔬', true],
|
||||
['emoji with non-BMP codepoint 🧑🚀', true],
|
||||
['emoji with combining marks 👨👩👧👦', true],
|
||||
['emoji with enclosing keycap #️⃣', true],
|
||||
['emoji with no visible glyph \u200D', false],
|
||||
] as const)(
|
||||
'stringHasUnicodeEmoji has emojis in "%s": %o',
|
||||
([text, expected], { expect }) => {
|
||||
expect(stringHasUnicodeEmoji(text)).toBe(expected);
|
||||
},
|
||||
);
|
||||
['😊', true],
|
||||
['🇿🇼', true],
|
||||
['🏴☠️', true],
|
||||
['🏳️🌈', true],
|
||||
['foo', false],
|
||||
[':smile:', false],
|
||||
['😊foo', false],
|
||||
] as const)('isUnicodeEmoji("%s") is %o', ([input, expected], { expect }) => {
|
||||
expect(isUnicodeEmoji(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCustomEmoji', () => {
|
||||
test.concurrent.for([
|
||||
[':smile:', true],
|
||||
[':smile_123:', true],
|
||||
[':SMILE:', true],
|
||||
['😊', false],
|
||||
['foo', false],
|
||||
[':smile', false],
|
||||
['smile:', false],
|
||||
] as const)('isCustomEmoji("%s") is %o', ([input, expected], { expect }) => {
|
||||
expect(isCustomEmoji(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringHasUnicodeFlags', () => {
|
||||
@ -51,27 +47,3 @@ describe('stringHasUnicodeFlags', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('stringHasCustomEmoji', () => {
|
||||
test('string with custom emoji returns true', () => {
|
||||
expect(stringHasCustomEmoji(':custom: :test:')).toBeTruthy();
|
||||
});
|
||||
test('string without custom emoji returns false', () => {
|
||||
expect(stringHasCustomEmoji('🏳️🌈 :🏳️🌈: text ™')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringHasAnyEmoji', () => {
|
||||
test('string without any emoji or characters', () => {
|
||||
expect(stringHasAnyEmoji('normal text. 12356?!')).toBeFalsy();
|
||||
});
|
||||
test('string with non-emoji characters', () => {
|
||||
expect(stringHasAnyEmoji('™©')).toBeFalsy();
|
||||
});
|
||||
test('has unicode emoji', () => {
|
||||
expect(stringHasAnyEmoji('🏳️🌈🔥🇸🇹 👩🔬')).toBeTruthy();
|
||||
});
|
||||
test('has custom emoji', () => {
|
||||
expect(stringHasAnyEmoji(':test: :custom:')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,10 +6,6 @@ export function emojiLogger(segment: string) {
|
||||
return debug(`emojis:${segment}`);
|
||||
}
|
||||
|
||||
export function stringHasUnicodeEmoji(input: string): boolean {
|
||||
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
|
||||
}
|
||||
|
||||
export function isUnicodeEmoji(input: string): boolean {
|
||||
return (
|
||||
input.length > 0 &&
|
||||
@ -34,19 +30,13 @@ export function stringHasUnicodeFlags(input: string): boolean {
|
||||
|
||||
// Constant as this is supported by all browsers.
|
||||
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
||||
// Use the polyfill regex or the Unicode property escapes if supported.
|
||||
const EMOJI_REGEX = emojiRegexPolyfill?.source ?? '\\p{RGI_Emoji}';
|
||||
|
||||
export function isCustomEmoji(input: string): boolean {
|
||||
return new RegExp(`^${CUSTOM_EMOJI_REGEX.source}$`, 'i').test(input);
|
||||
}
|
||||
|
||||
export function stringHasCustomEmoji(input: string) {
|
||||
return CUSTOM_EMOJI_REGEX.test(input);
|
||||
}
|
||||
|
||||
export function stringHasAnyEmoji(input: string) {
|
||||
return stringHasUnicodeEmoji(input) || stringHasCustomEmoji(input);
|
||||
}
|
||||
|
||||
export function anyEmojiRegex() {
|
||||
return new RegExp(
|
||||
`${EMOJI_REGEX}|${CUSTOM_EMOJI_REGEX.source}`,
|
||||
@ -64,5 +54,3 @@ function supportedFlags(flags = '') {
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const EMOJI_REGEX = emojiRegexPolyfill?.source ?? '\\p{RGI_Emoji}';
|
||||
|
||||
@ -16,15 +16,21 @@ import { expandHashtagTimeline, clearTimeline } from 'mastodon/actions/timelines
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { remoteTopicFeedAccess, me } from 'mastodon/initial_state';
|
||||
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
|
||||
import { HashtagHeader } from './components/hashtag_header';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
|
||||
});
|
||||
const mapStateToProps = (state, props) => {
|
||||
const local = props.params.local || (!me && remoteTopicFeedAccess !== 'public');
|
||||
|
||||
return ({
|
||||
local,
|
||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${local ? ':local' : ''}`, 'unread']) > 0,
|
||||
});
|
||||
};
|
||||
|
||||
class HashtagTimeline extends PureComponent {
|
||||
disconnects = [];
|
||||
@ -113,16 +119,16 @@ class HashtagTimeline extends PureComponent {
|
||||
}
|
||||
|
||||
_unload () {
|
||||
const { dispatch } = this.props;
|
||||
const { id, local } = this.props.params;
|
||||
const { dispatch, local } = this.props;
|
||||
const { id } = this.props.params;
|
||||
|
||||
this._unsubscribe();
|
||||
dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
|
||||
}
|
||||
|
||||
_load() {
|
||||
const { dispatch } = this.props;
|
||||
const { id, tags, local } = this.props.params;
|
||||
const { dispatch, local } = this.props;
|
||||
const { id, tags } = this.props.params;
|
||||
|
||||
this._subscribe(dispatch, id, tags, local);
|
||||
dispatch(expandHashtagTimeline(id, { tags, local }));
|
||||
@ -133,8 +139,8 @@ class HashtagTimeline extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { params } = this.props;
|
||||
const { id, tags, local } = prevProps.params;
|
||||
const { params, local } = this.props;
|
||||
const { id, tags } = prevProps.params;
|
||||
|
||||
if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
|
||||
this._unload();
|
||||
@ -151,15 +157,15 @@ class HashtagTimeline extends PureComponent {
|
||||
};
|
||||
|
||||
handleLoadMore = maxId => {
|
||||
const { dispatch, params } = this.props;
|
||||
const { id, tags, local } = params;
|
||||
const { dispatch, params, local } = this.props;
|
||||
const { id, tags } = params;
|
||||
|
||||
dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { hasUnread, columnId, multiColumn } = this.props;
|
||||
const { id, local } = this.props.params;
|
||||
const { hasUnread, columnId, multiColumn, local } = this.props;
|
||||
const { id } = this.props.params;
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
|
||||
@ -34,6 +34,8 @@ interface InitialStateMeta {
|
||||
streaming_api_base_url: string;
|
||||
local_live_feed_access: 'public' | 'authenticated';
|
||||
remote_live_feed_access: 'public' | 'authenticated';
|
||||
local_topic_feed_access: 'public' | 'authenticated';
|
||||
remote_topic_feed_access: 'public' | 'authenticated';
|
||||
title: string;
|
||||
show_trends: boolean;
|
||||
trends_as_landing_page: boolean;
|
||||
@ -113,6 +115,8 @@ export const singleUserMode = getMeta('single_user_mode');
|
||||
export const source_url = getMeta('source_url');
|
||||
export const localLiveFeedAccess = getMeta('local_live_feed_access');
|
||||
export const remoteLiveFeedAccess = getMeta('remote_live_feed_access');
|
||||
export const localTopicFeedAccess = getMeta('local_topic_feed_access');
|
||||
export const remoteTopicFeedAccess = getMeta('remote_topic_feed_access');
|
||||
export const title = getMeta('title');
|
||||
export const trendsAsLanding = getMeta('trends_as_landing_page');
|
||||
export const useBlurhash = getMeta('use_blurhash');
|
||||
|
||||
@ -257,7 +257,12 @@
|
||||
"confirmations.revoke_quote.confirm": "Dileu'r postiad",
|
||||
"confirmations.revoke_quote.message": "Does dim modd dadwneud y weithred hon.",
|
||||
"confirmations.revoke_quote.title": "Dileu'r postiad?",
|
||||
"confirmations.unblock.confirm": "Dadrwystro",
|
||||
"confirmations.unblock.title": "Dadrwystro {name}?",
|
||||
"confirmations.unfollow.confirm": "Dad-ddilyn",
|
||||
"confirmations.unfollow.title": "Dad-ddilyn {name}",
|
||||
"confirmations.withdraw_request.confirm": "Tynnu'r cais yn ôl",
|
||||
"confirmations.withdraw_request.title": "Tynnu nôl y cais i ddilyn {name}?",
|
||||
"content_warning.hide": "Cuddio'r postiad",
|
||||
"content_warning.show": "Dangos beth bynnag",
|
||||
"content_warning.show_more": "Dangos rhagor",
|
||||
@ -915,8 +920,10 @@
|
||||
"status.quote_policy_change": "Newid pwy all ddyfynnu",
|
||||
"status.quote_post_author": "Wedi dyfynnu postiad gan @{name}",
|
||||
"status.quote_private": "Does dim modd dyfynnu postiadau preifat",
|
||||
"status.quotes": "{count, plural, zero {}one {dyfyniad} two {ddyfyniad} few {dyfyniad} many {dyfyniad} other {dyfyniad}}",
|
||||
"status.quotes": "{count, plural, zero {dyfyniadau} one {dyfyniad} two {ddyfyniad} few {dyfyniad} many {dyfyniad} other {dyfyniad}}",
|
||||
"status.quotes.empty": "Does neb wedi dyfynnu'r postiad hwn eto. Pan fydd rhywun yn gwneud hynny, bydd yn ymddangos yma.",
|
||||
"status.quotes.local_other_disclaimer": "Bydd dyfyniadau wedi'u gwrthod gan yr awdur ddim yn cael eu dangos.",
|
||||
"status.quotes.remote_other_disclaimer": "Dim ond dyfyniadau o {domain} sy'n siŵr o gael eu dangos yma. Bydd dyfyniadau wedi'u gwrthod gan yr awdur ddim yn cael eu dangos.",
|
||||
"status.read_more": "Darllen rhagor",
|
||||
"status.reblog": "Hybu",
|
||||
"status.reblog_or_quote": "Hybu neu ddyfynnu",
|
||||
|
||||
@ -172,7 +172,7 @@
|
||||
"column.domain_blocks": "Blokerede domæner",
|
||||
"column.edit_list": "Redigér liste",
|
||||
"column.favourites": "Favoritter",
|
||||
"column.firehose": "Aktuelt",
|
||||
"column.firehose": "Live feeds",
|
||||
"column.follow_requests": "Følgeanmodninger",
|
||||
"column.home": "Hjem",
|
||||
"column.list_members": "Håndtér listemedlemmer",
|
||||
@ -574,8 +574,8 @@
|
||||
"navigation_bar.follows_and_followers": "Følges og følgere",
|
||||
"navigation_bar.import_export": "Import og eksport",
|
||||
"navigation_bar.lists": "Lister",
|
||||
"navigation_bar.live_feed_local": "Aktuelt (lokalt)",
|
||||
"navigation_bar.live_feed_public": "Aktuelt (offentligt)",
|
||||
"navigation_bar.live_feed_local": "Live feed (lokalt)",
|
||||
"navigation_bar.live_feed_public": "Live feed (offentligt)",
|
||||
"navigation_bar.logout": "Log af",
|
||||
"navigation_bar.moderation": "Moderering",
|
||||
"navigation_bar.more": "Mere",
|
||||
|
||||
@ -753,6 +753,7 @@
|
||||
"privacy.unlisted.short": "Quiet public",
|
||||
"privacy_policy.last_updated": "Last updated {date}",
|
||||
"privacy_policy.title": "Privacy Policy",
|
||||
"quote_error.edit": "Quotes cannot be added when editing a post.",
|
||||
"quote_error.poll": "Quoting is not allowed with polls.",
|
||||
"quote_error.quote": "Only one quote at a time is allowed.",
|
||||
"quote_error.unauthorized": "You are not authorized to quote this post.",
|
||||
|
||||
@ -923,7 +923,7 @@
|
||||
"status.quotes": "{count, plural,one {cita} other {citas}}",
|
||||
"status.quotes.empty": "Nadie ha citado esta publicación todavía. Cuando alguien lo haga, aparecerá aquí.",
|
||||
"status.quotes.local_other_disclaimer": "Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.quotes.remote_other_disclaimer": "Solo se muestran las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.quotes.remote_other_disclaimer": "Solo se garantiza que se muestren las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.read_more": "Leer más",
|
||||
"status.reblog": "Impulsar",
|
||||
"status.reblog_or_quote": "Impulsar o citar",
|
||||
|
||||
@ -923,7 +923,7 @@
|
||||
"status.quotes": "{count, plural,one {cita} other {citas}}",
|
||||
"status.quotes.empty": "Nadie ha citado esta publicación todavía. Cuando alguien lo haga, aparecerá aquí.",
|
||||
"status.quotes.local_other_disclaimer": "Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.quotes.remote_other_disclaimer": "Solo se muestran las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.quotes.remote_other_disclaimer": "Solo se garantiza que se muestren las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
|
||||
"status.read_more": "Leer más",
|
||||
"status.reblog": "Impulsar",
|
||||
"status.reblog_or_quote": "Impulsar o citar",
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
"account.follow": "跟隨",
|
||||
"account.follow_back": "跟隨回去",
|
||||
"account.follow_back_short": "跟隨回去",
|
||||
"account.follow_request": "要求跟隨",
|
||||
"account.follow_request": "跟隨",
|
||||
"account.follow_request_cancel": "取消跟隨請求",
|
||||
"account.follow_request_cancel_short": "取消",
|
||||
"account.follow_request_short": "跟隨請求",
|
||||
|
||||
@ -1528,7 +1528,7 @@
|
||||
& > .status__content,
|
||||
& > .status__action-bar,
|
||||
& > .media-gallery,
|
||||
& > .video-player,
|
||||
& > div > .video-player,
|
||||
& > .audio-player,
|
||||
& > .attachment-list,
|
||||
& > .picture-in-picture-placeholder,
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import type { CompactEmoji } from 'emojibase';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { relationshipsFactory } from './factories';
|
||||
import { toSupportedLocale } from '@/mastodon/features/emoji/locale';
|
||||
|
||||
import { customEmojiFactory, relationshipsFactory } from './factories';
|
||||
|
||||
export const mockHandlers = {
|
||||
mute: http.post<{ id: string }>('/api/v1/accounts/:id/mute', ({ params }) => {
|
||||
@ -40,6 +43,24 @@ export const mockHandlers = {
|
||||
);
|
||||
},
|
||||
),
|
||||
emojiCustomData: http.get('/api/v1/custom_emojis', () => {
|
||||
action('fetching custom emoji data')();
|
||||
return HttpResponse.json([customEmojiFactory()]);
|
||||
}),
|
||||
emojiData: http.get<{ locale: string }>(
|
||||
'/packs-dev/emoji/:locale.json',
|
||||
async ({ params }) => {
|
||||
const locale = toSupportedLocale(params.locale);
|
||||
action('fetching emoji data')(locale);
|
||||
const { default: data } = (await import(
|
||||
`emojibase-data/${locale}/compact.json`
|
||||
)) as {
|
||||
default: CompactEmoji[];
|
||||
};
|
||||
|
||||
return HttpResponse.json([data]);
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
export const unhandledRequestHandler = ({ url }: Request) => {
|
||||
|
||||
@ -59,9 +59,11 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
|
||||
@quote = Quote.find_by(approval_uri: object_uri, quoted_account: @account)
|
||||
return if @quote.nil?
|
||||
|
||||
ActivityPub::Forwarder.new(@account, @json, @quote.status).forward!
|
||||
ActivityPub::Forwarder.new(@account, @json, @quote.status).forward! if @quote.status.present?
|
||||
|
||||
@quote.reject!
|
||||
DistributionWorker.perform_async(@quote.status_id, { 'update' => true })
|
||||
|
||||
DistributionWorker.perform_async(@quote.status_id, { 'update' => true }) if @quote.status.present?
|
||||
end
|
||||
|
||||
def forwarder
|
||||
|
||||
@ -135,6 +135,8 @@ class InitialStateSerializer < ActiveModel::Serializer
|
||||
terms_of_service_enabled: TermsOfService.current.present?,
|
||||
local_live_feed_access: Setting.local_live_feed_access,
|
||||
remote_live_feed_access: Setting.remote_live_feed_access,
|
||||
local_topic_feed_access: Setting.local_topic_feed_access,
|
||||
remote_topic_feed_access: Setting.remote_topic_feed_access,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@ -21,6 +21,11 @@ class RevokeQuoteService < BaseService
|
||||
end
|
||||
|
||||
def distribute_stamp_deletion!
|
||||
# It is possible the quoted status has been soft-deleted.
|
||||
# In this case, `signed_activity_json` would fail, but we can just ignore
|
||||
# that, as we have already federated deletion.
|
||||
return if @quote.quoted_status.nil?
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(inboxes, limit: 1_000) do |inbox_url|
|
||||
[signed_activity_json, @account.id, inbox_url]
|
||||
end
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
|
||||
%tr
|
||||
%td.email-status-content
|
||||
= render 'status_content', status: status
|
||||
= render 'notification_mailer/status_content', status: status
|
||||
|
||||
%p.email-status-footer
|
||||
= link_to l(status.created_at.in_time_zone(time_zone.presence), format: :with_time_zone), web_url("@#{status.account.pretty_acct}/#{status.id}")
|
||||
|
||||
@ -11,12 +11,12 @@
|
||||
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
|
||||
%tr
|
||||
%td.email-status-content
|
||||
= render 'status_content', status: status
|
||||
= render 'notification_mailer/status_content', status: status
|
||||
|
||||
- if status.local? && status.quote
|
||||
%table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
|
||||
%tr
|
||||
%td.email-inner-nested-card-td
|
||||
= render 'nested_quote', status: status.quote.quoted_status, time_zone: time_zone
|
||||
= render 'notification_mailer/nested_quote', status: status.quote.quoted_status, time_zone: time_zone
|
||||
%p.email-status-footer
|
||||
= link_to l(status.created_at.in_time_zone(time_zone.presence), format: :with_time_zone), web_url("@#{status.account.pretty_acct}/#{status.id}")
|
||||
|
||||
@ -904,6 +904,10 @@ cy:
|
||||
all: I bawb
|
||||
disabled: I neb
|
||||
users: I ddefnyddwyr lleol wedi'u mewngofnodi
|
||||
feed_access:
|
||||
modes:
|
||||
authenticated: Defnyddwyr dilys yn unig
|
||||
public: Pawb
|
||||
registrations:
|
||||
moderation_recommandation: Gwnewch yn siŵr bod gennych chi dîm cymedroli digonol ac adweithiol cyn i chi agor cofrestriadau i bawb!
|
||||
preamble: Rheoli pwy all greu cyfrif ar eich gweinydd.
|
||||
@ -1743,6 +1747,13 @@ cy:
|
||||
expires_at: Yn dod i ben ar
|
||||
uses: Defnyddiau
|
||||
title: Gwahodd pobl
|
||||
link_preview:
|
||||
author_html: Yn ôl %{name}
|
||||
potentially_sensitive_content:
|
||||
action: Cliciwch i ddangos
|
||||
confirm_visit: Ydych chi'n siŵr eich bod chi eisiau agor y ddolen hon?
|
||||
hide_button: Cuddio
|
||||
label: Cynnwys a allai fod yn sensitif
|
||||
lists:
|
||||
errors:
|
||||
limit: Rydych chi wedi cyrraedd y nifer mwyaf o restrau
|
||||
@ -2065,6 +2076,9 @@ cy:
|
||||
zero: "%{count} fideo"
|
||||
boosted_from_html: Wedi'i hybu o %{acct_link}
|
||||
content_warning: 'Rhybudd cynnwys: %{warning}'
|
||||
content_warnings:
|
||||
hide: Cuddio'r postiad
|
||||
show: Dangos rhagor
|
||||
default_language: Yr un fath a'r iaith rhyngwyneb
|
||||
disallowed_hashtags:
|
||||
few: 'yn cynnwys yr hashnod gwaharddedig: %{tags}'
|
||||
|
||||
@ -282,17 +282,21 @@ cy:
|
||||
activity_api_enabled: Cyhoeddi ystadegau cyfanredol am weithgarwch defnyddwyr yn yr API
|
||||
app_icon: Eicon ap
|
||||
backups_retention_period: Cyfnod cadw archif defnyddwyr
|
||||
bootstrap_timeline_accounts: Argymhellwch y cyfrifon hyn i ddefnyddwyr newydd bob amser
|
||||
bootstrap_timeline_accounts: Argymhellwch y cyfrifon hyn i ddefnyddwyr newydd bob tro
|
||||
closed_registrations_message: Neges bersonol pan nad yw cofrestriadau ar gael
|
||||
content_cache_retention_period: Cyfnod cadw cynnwys o bell
|
||||
custom_css: CSS cyfaddas
|
||||
favicon: Favicon
|
||||
local_live_feed_access: Mynediad i ffrydiau byw sy'n cynnwys postiadau lleol
|
||||
local_topic_feed_access: Mynediad i ffrydiau hashnod a dolenni sy'n cynnwys postiadau lleol
|
||||
mascot: Mascot cyfaddas (hen)
|
||||
media_cache_retention_period: Cyfnod cadw storfa cyfryngau
|
||||
min_age: Gofyniad oed ieuengaf
|
||||
peers_api_enabled: Cyhoeddi rhestr o weinyddion a ddarganfuwyd yn yr API
|
||||
profile_directory: Galluogi cyfeiriadur proffil
|
||||
registrations_mode: Pwy all gofrestru
|
||||
remote_live_feed_access: Mynediad i ffrydiau byw sy'n cynnwys postiadau pell
|
||||
remote_topic_feed_access: Mynediad i ffrydiau hashnod a dolenni sy'n cynnwys postiadau o bell
|
||||
require_invite_text: Gofyn am reswm i ymuno
|
||||
show_domain_blocks: Dangos blociau parth
|
||||
show_domain_blocks_rationale: Dangos pam y cafodd parthau eu rhwystro
|
||||
|
||||
@ -142,6 +142,7 @@ sq:
|
||||
arbitration_address: Mund të jetë e njëjtë me adresën Fizike më sipër, ose “N/A”, nëse përdoret email.
|
||||
arbitration_website: Mund të jetë një formular web, ose “N/A”, nëse përdoret email.
|
||||
choice_of_law: Qytet, rajon, territor ose shtet, ligjet e brendshme të të cilit do të administrojnë çfarëdo dhe tërë pretendimet.
|
||||
dmca_address: Për operatorë në ShBA, përdorni adresën e regjistruar te DMCA Designated Agent Directory. Regjistrimi me A P.O. Box bëhet me kërkesë të drejtpërdrejtë, përdorni DMCA Designated Agent Post Office Box Waiver Request që t’i dërgoni email Copyright Office-it dhe t’i përshkruani se jeni një moderator shtëpiak lënde që ka frikë nga hakmarrje apo ndëshkim për veprimet tuaja dhe që ka nevojë të përdor një P.O. Box për heqjen e adresës së shtëpisë tuaj nga sytë e publikut.
|
||||
dmca_email: Mund të jetë i njëjti email i përdorur për “Adresë email për njoftime ligjore” më sipër.
|
||||
domain: Identifikues unik për shërbimin internetor që po ofroni.
|
||||
jurisdiction: Vendosni vendin ku jeton cilido që paguan faturat. Nëse është një shoqëri apo tjetër njësi, vendosni vendin ku është regjistruar, si dhe qytetin, rajonin, territorin apo shtetin përkatës.
|
||||
|
||||
@ -39,7 +39,7 @@ defaults: &defaults
|
||||
require_invite_text: false
|
||||
backups_retention_period: 7
|
||||
captcha_enabled: false
|
||||
allow_referer_origin: false
|
||||
allow_referrer_origin: false
|
||||
|
||||
development:
|
||||
<<: *defaults
|
||||
|
||||
@ -141,7 +141,9 @@ RSpec.describe UserMailer do
|
||||
end
|
||||
|
||||
describe '#warning' do
|
||||
let(:strike) { Fabricate(:account_warning, target_account: receiver.account, text: 'dont worry its just the testsuite', action: 'suspend') }
|
||||
let(:status) { Fabricate(:status, account: receiver.account) }
|
||||
let(:quote) { Fabricate(:quote, state: :accepted, status: status) }
|
||||
let(:strike) { Fabricate(:account_warning, target_account: receiver.account, text: 'dont worry its just the testsuite', action: 'suspend', status_ids: [quote.status_id]) }
|
||||
let(:mail) { described_class.warning(receiver, strike) }
|
||||
|
||||
it 'renders warning notification' do
|
||||
|
||||
@ -129,4 +129,20 @@ RSpec.describe RemoveStatusService, :inline_jobs do
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
context 'when removed status is a quote of a local user', inline_jobs: false do
|
||||
let(:original_status) { Fabricate(:status, account: alice) }
|
||||
let(:status) { Fabricate(:status, account: jeff) }
|
||||
|
||||
before do
|
||||
bill.follow!(jeff)
|
||||
Fabricate(:quote, status: status, quoted_status: original_status, state: :accepted)
|
||||
original_status.discard
|
||||
end
|
||||
|
||||
it 'sends deletion without crashing' do
|
||||
expect { subject.call(status.reload) }
|
||||
.to enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(/Delete/, jeff.id, bill.inbox_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user