From 9a001e7839223ba896b30d8ee71c5e5d720ca8fb Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 14 Oct 2025 10:58:03 +0200 Subject: [PATCH 01/14] Fix videos not being indented properly in thread view (#36459) --- app/javascript/styles/mastodon/components.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 7e677b8ef4..ee54cd71ad 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1528,7 +1528,7 @@ & > .status__content, & > .status__action-bar, & > .media-gallery, - & > .video-player, + & > div > .video-player, & > .audio-player, & > .attachment-list, & > .picture-in-picture-placeholder, From 0c64e7f75e1855b5375444aa19b8676ef4b3ca0d Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 14 Oct 2025 11:36:25 +0200 Subject: [PATCH 02/14] Emoji: Cleanup new code (#36402) --- .storybook/preview.tsx | 8 +- .../components/emoji/emoji.stories.tsx | 56 +++ .../mastodon/components/emoji/html.tsx | 2 +- .../mastodon/components/emoji/index.tsx | 2 +- .../html_block/html_block.stories.tsx | 38 +- .../mastodon/components/html_block/index.tsx | 72 ++-- .../mastodon/features/emoji/emoji_picker.tsx | 2 +- .../mastodon/features/emoji/hooks.ts | 78 ---- .../mastodon/features/emoji/index.ts | 4 +- .../mastodon/features/emoji/mode.ts | 23 +- .../mastodon/features/emoji/render.test.ts | 198 +++++---- .../mastodon/features/emoji/render.ts | 403 +++--------------- .../mastodon/features/emoji/types.ts | 10 +- .../mastodon/features/emoji/utils.test.ts | 80 ++-- .../mastodon/features/emoji/utils.ts | 16 +- app/javascript/testing/api.ts | 23 +- 16 files changed, 356 insertions(+), 659 deletions(-) create mode 100644 app/javascript/mastodon/components/emoji/emoji.stories.tsx delete mode 100644 app/javascript/mastodon/features/emoji/hooks.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index fcba923030..d66f0fb11a 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -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, + argsState as Record, ); + const store = configureStore({ reducer, middleware(getDefaultMiddleware) { diff --git a/app/javascript/mastodon/components/emoji/emoji.stories.tsx b/app/javascript/mastodon/components/emoji/emoji.stories.tsx new file mode 100644 index 0000000000..a5e283158d --- /dev/null +++ b/app/javascript/mastodon/components/emoji/emoji.stories.tsx @@ -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 & { 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 ; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomEmoji: Story = { + args: { + code: ':custom:', + }, +}; diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx index b462a2ee6f..628385f649 100644 --- a/app/javascript/mastodon/components/emoji/html.tsx +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -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; diff --git a/app/javascript/mastodon/components/emoji/index.tsx b/app/javascript/mastodon/components/emoji/index.tsx index e070eb30dd..0dff8314ff 100644 --- a/app/javascript/mastodon/components/emoji/index.tsx +++ b/app/javascript/mastodon/components/emoji/index.tsx @@ -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, diff --git a/app/javascript/mastodon/components/html_block/html_block.stories.tsx b/app/javascript/mastodon/components/html_block/html_block.stories.tsx index 9c104ba45c..6fb3206df0 100644 --- a/app/javascript/mastodon/components/html_block/html_block.stories.tsx +++ b/app/javascript/mastodon/components/html_block/html_block.stories.tsx @@ -7,23 +7,49 @@ const meta = { title: 'Components/HTMLBlock', component: HTMLBlock, args: { - contents: - '

Hello, world!

\n

A link

\n

This should be filtered out:

', + htmlString: `

Hello, world!

+

A link

+

This should be filtered out:

+

This also has emoji: 🖤

`, + }, + argTypes: { + extraEmojis: { + table: { + disable: true, + }, + }, + onElement: { + table: { + disable: true, + }, + }, + onAttribute: { + table: { + disable: true, + }, + }, }, render(args) { return ( // Just for visual clarity in Storybook. -
- -
+ /> ); }, + // Force Twemoji to demonstrate emoji rendering. + parameters: { + state: { + meta: { + emoji_style: 'twemoji', + }, + }, + }, } satisfies Meta; export default meta; diff --git a/app/javascript/mastodon/components/html_block/index.tsx b/app/javascript/mastodon/components/html_block/index.tsx index 51baea614d..69fa1d9bf5 100644 --- a/app/javascript/mastodon/components/html_block/index.tsx +++ b/app/javascript/mastodon/components/html_block/index.tsx @@ -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({ maxSize: 1000 }); - -interface HTMLBlockProps { - contents: string; - extraEmojis?: CustomEmojiMapArg; -} - -export const HTMLBlock: FC = ({ - 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[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 ; + }, +); diff --git a/app/javascript/mastodon/features/emoji/emoji_picker.tsx b/app/javascript/mastodon/features/emoji/emoji_picker.tsx index 6dcfe37ac8..f9367c775c 100644 --- a/app/javascript/mastodon/features/emoji/emoji_picker.tsx +++ b/app/javascript/mastodon/features/emoji/emoji_picker.tsx @@ -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`; diff --git a/app/javascript/mastodon/features/emoji/hooks.ts b/app/javascript/mastodon/features/emoji/hooks.ts deleted file mode 100644 index dbd20d3044..0000000000 --- a/app/javascript/mastodon/features/emoji/hooks.ts +++ /dev/null @@ -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(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'), - }; -} diff --git a/app/javascript/mastodon/features/emoji/index.ts b/app/javascript/mastodon/features/emoji/index.ts index d128da6b53..3701ad6767 100644 --- a/app/javascript/mastodon/features/emoji/index.ts +++ b/app/javascript/mastodon/features/emoji/index.ts @@ -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) => { const { data: message } = event; if (message === 'ready') { diff --git a/app/javascript/mastodon/features/emoji/mode.ts b/app/javascript/mastodon/features/emoji/mode.ts index 0f581d8b50..afb8a78ebc 100644 --- a/app/javascript/mastodon/features/emoji/mode.ts +++ b/app/javascript/mastodon/features/emoji/mode.ts @@ -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; diff --git a/app/javascript/mastodon/features/emoji/render.test.ts b/app/javascript/mastodon/features/emoji/render.test.ts index 108cf74750..05dbc388c4 100644 --- a/app/javascript/mastodon/features/emoji/render.test.ts +++ b/app/javascript/mastodon/features/emoji/render.test.ts @@ -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 = - '😊'; -const expectedFlagImage = - '🇪🇺'; - -function testAppState(state: Partial = {}) { - return { - locales: ['en'], - mode: EMOJI_MODE_TWEMOJI, - currentLocale: 'en', - darkTheme: false, - ...state, - } satisfies EmojiAppState; -} - -describe('emojifyElement', () => { - function testElement(text = '

Hello 😊🇪🇺!

:custom:

') { - 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('

here is just text :)

'), - 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(); + }); +}); diff --git a/app/javascript/mastodon/features/emoji/render.ts b/app/javascript/mastodon/features/emoji/render.ts index e0c8fd8dce..574d5ef59b 100644 --- a/app/javascript/mastodon/features/emoji/render.ts +++ b/app/javascript/mastodon/features/emoji/render.ts @@ -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: Element, - appState: EmojiAppState, - extraEmojis: ExtraCustomEmojiMap = {}, -): Promise { - 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 { - 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({ 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 { - // 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([ - [ - EMOJI_TYPE_CUSTOM, - createLimitedCache({ log: log.extend('custom') }), - ], -]); - -function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap { - return ( - localeCacheMap.get(locale) ?? - createLimitedCache({ 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(); - const missingCustomEmoji = new Set(); - - // 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( - 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(); -}; diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts index b55cefb0a5..8cd0902aa4 100644 --- a/app/javascript/mastodon/features/emoji/types.ts +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -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 | Required; -export type EmojiStateMap = LimitedCache; - export type CustomEmojiMapArg = | ExtraCustomEmojiMap | ImmutableList @@ -64,9 +62,3 @@ export type ExtraCustomEmojiMap = Record< string, Pick >; - -export interface TwemojiBorderInfo { - hexCode: string; - hasLightBorder: boolean; - hasDarkBorder: boolean; -} diff --git a/app/javascript/mastodon/features/emoji/utils.test.ts b/app/javascript/mastodon/features/emoji/utils.test.ts index b9062294c4..3844c814a1 100644 --- a/app/javascript/mastodon/features/emoji/utils.test.ts +++ b/app/javascript/mastodon/features/emoji/utils.test.ts @@ -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(); - }); -}); diff --git a/app/javascript/mastodon/features/emoji/utils.ts b/app/javascript/mastodon/features/emoji/utils.ts index e811565c27..c567afc2cc 100644 --- a/app/javascript/mastodon/features/emoji/utils.ts +++ b/app/javascript/mastodon/features/emoji/utils.ts @@ -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}'; diff --git a/app/javascript/testing/api.ts b/app/javascript/testing/api.ts index 4948d71997..dd45b7e7b6 100644 --- a/app/javascript/testing/api.ts +++ b/app/javascript/testing/api.ts @@ -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) => { From 44ecc4b1e325f3c7d01d5f8295a681ec765f244d Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 14 Oct 2025 13:48:51 +0200 Subject: [PATCH 03/14] Fix moderation warning e-mails that include posts (#36462) --- app/views/notification_mailer/_nested_quote.html.haml | 2 +- app/views/notification_mailer/_status.html.haml | 4 ++-- spec/mailers/user_mailer_spec.rb | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/views/notification_mailer/_nested_quote.html.haml b/app/views/notification_mailer/_nested_quote.html.haml index e66736399f..dc0921c2ed 100644 --- a/app/views/notification_mailer/_nested_quote.html.haml +++ b/app/views/notification_mailer/_nested_quote.html.haml @@ -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}") diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml index 064709e7da..c56c7ec72c 100644 --- a/app/views/notification_mailer/_status.html.haml +++ b/app/views/notification_mailer/_status.html.haml @@ -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}") diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 88f9d12cac..82021cd3d0 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -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 From f2f711deeb9b4552af8f7352e0b5183d5d7c0748 Mon Sep 17 00:00:00 2001 From: Jonathan de Jong Date: Tue, 14 Oct 2025 14:07:15 +0200 Subject: [PATCH 04/14] Fix allow_referrer_origin typo (#36460) --- config/settings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/settings.yml b/config/settings.yml index 9dfab1bbe7..c6478e57d4 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -32,7 +32,7 @@ defaults: &defaults require_invite_text: false backups_retention_period: 7 captcha_enabled: false - allow_referer_origin: false + allow_referrer_origin: false development: <<: *defaults From ad858ebe811728c1ba49b202e93cb02b742690bd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:17:40 +0200 Subject: [PATCH 05/14] Update dependency strong_migrations to v2.5.1 (#36458) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index ee5f481e34..1d63e0d955 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) From 156f031ed046244b26016df92dbf2c72d3b23c3d Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 14 Oct 2025 14:21:13 +0200 Subject: [PATCH 06/14] Fix WebUI mistakenly allowing to attach quotes when editing (#36464) --- app/javascript/mastodon/actions/compose_typed.ts | 11 +++++++++-- app/javascript/mastodon/locales/en.json | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index ed925914f9..0f9bf5cfb3 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -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; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index fd9f35629b..5ecf32ff6d 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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.", From 484225895f211c99c55e196f50e73959eecf8097 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:26:21 +0200 Subject: [PATCH 07/14] New Crowdin Translations (automated) (#36457) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/cy.json | 9 ++++++++- app/javascript/mastodon/locales/da.json | 6 +++--- app/javascript/mastodon/locales/es-MX.json | 2 +- app/javascript/mastodon/locales/es.json | 2 +- app/javascript/mastodon/locales/zh-TW.json | 2 +- config/locales/cy.yml | 14 ++++++++++++++ config/locales/simple_form.cy.yml | 6 +++++- config/locales/simple_form.sq.yml | 1 + 8 files changed, 34 insertions(+), 8 deletions(-) diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json index 5378330c3a..7bd83922d9 100644 --- a/app/javascript/mastodon/locales/cy.json +++ b/app/javascript/mastodon/locales/cy.json @@ -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", diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json index 92dd5014db..255b41e871 100644 --- a/app/javascript/mastodon/locales/da.json +++ b/app/javascript/mastodon/locales/da.json @@ -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", diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json index 8c00edc992..59b0b68c88 100644 --- a/app/javascript/mastodon/locales/es-MX.json +++ b/app/javascript/mastodon/locales/es-MX.json @@ -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", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index fc86d60848..9985cdea1e 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -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", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 35e7250f0e..20f94573d7 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -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": "跟隨請求", diff --git a/config/locales/cy.yml b/config/locales/cy.yml index 7fe0e7dd85..e6dfd21b15 100644 --- a/config/locales/cy.yml +++ b/config/locales/cy.yml @@ -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}' diff --git a/config/locales/simple_form.cy.yml b/config/locales/simple_form.cy.yml index 354d76a65d..1c37bcbedb 100644 --- a/config/locales/simple_form.cy.yml +++ b/config/locales/simple_form.cy.yml @@ -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 diff --git a/config/locales/simple_form.sq.yml b/config/locales/simple_form.sq.yml index a942a2d030..dee0d44a63 100644 --- a/config/locales/simple_form.sq.yml +++ b/config/locales/simple_form.sq.yml @@ -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. From 3232eee358134ca468b40ec3b517a15fe69b7fe6 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 14 Oct 2025 16:04:18 +0200 Subject: [PATCH 08/14] Fix error in logged-out hashtag view when remote posts require log-in (#36467) --- .../features/hashtag_timeline/index.jsx | 32 +++++++++++-------- app/javascript/mastodon/initial_state.ts | 4 +++ app/serializers/initial_state_serializer.rb | 2 ++ 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.jsx b/app/javascript/mastodon/features/hashtag_timeline/index.jsx index ab3c32e6a7..1a7ffa6c23 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.jsx +++ b/app/javascript/mastodon/features/hashtag_timeline/index.jsx @@ -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 ( diff --git a/app/javascript/mastodon/initial_state.ts b/app/javascript/mastodon/initial_state.ts index 2bef1209e8..f28d81a10c 100644 --- a/app/javascript/mastodon/initial_state.ts +++ b/app/javascript/mastodon/initial_state.ts @@ -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'); diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index b190511726..93c06b7676 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -118,6 +118,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 From fd516347cb453c4354531fbb3cf973268e99a18d Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 14 Oct 2025 17:15:38 +0200 Subject: [PATCH 09/14] Fix deletion of posts quoting soft-deleted local post (#36461) --- app/services/revoke_quote_service.rb | 5 +++++ spec/services/remove_status_service_spec.rb | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/app/services/revoke_quote_service.rb b/app/services/revoke_quote_service.rb index 1dd37d0555..346fba8970 100644 --- a/app/services/revoke_quote_service.rb +++ b/app/services/revoke_quote_service.rb @@ -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 diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb index f2b46f05b0..3cb2eceec5 100644 --- a/spec/services/remove_status_service_spec.rb +++ b/spec/services/remove_status_service_spec.rb @@ -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 From 50743cc35be19deae9356bd21f1143e3a9b4fbb8 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 14 Oct 2025 17:15:45 +0200 Subject: [PATCH 10/14] Fix forwarder being called with `nil` status when quote post is soft-deleted (#36463) --- app/lib/activitypub/activity/delete.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index ce36cfe763..3e77f9b955 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -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 From a7f207604a310ad2a8ffcfda5ce627a8cd92a502 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 14 Oct 2025 10:58:03 +0200 Subject: [PATCH 11/14] [Glitch] Fix videos not being indented properly in thread view Port 9a001e7839223ba896b30d8ee71c5e5d720ca8fb to glitch-soc Signed-off-by: Claire --- app/javascript/flavours/glitch/styles/components.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 0457f7515e..ef80090850 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -1587,7 +1587,7 @@ & > .status__content, & > .status__action-bar, & > .media-gallery, - & > .video-player, + & > div > .video-player, & > .audio-player, & > .attachment-list, & > .picture-in-picture-placeholder, From 8d1e67b6b242e8843378564adcbe5dc932c62f67 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 14 Oct 2025 11:36:25 +0200 Subject: [PATCH 12/14] [Glitch] Emoji: Cleanup new code Port 0c64e7f75e1855b5375444aa19b8676ef4b3ca0d to glitch-soc Signed-off-by: Claire --- .../glitch/components/emoji/emoji.stories.tsx | 56 +++ .../flavours/glitch/components/emoji/html.tsx | 2 +- .../glitch/components/emoji/index.tsx | 2 +- .../html_block/html_block.stories.tsx | 38 +- .../glitch/components/html_block/index.tsx | 72 ++-- .../glitch/features/emoji/emoji_picker.tsx | 2 +- .../flavours/glitch/features/emoji/hooks.ts | 78 ---- .../flavours/glitch/features/emoji/index.ts | 4 +- .../flavours/glitch/features/emoji/mode.ts | 23 +- .../glitch/features/emoji/render.test.ts | 198 +++++---- .../flavours/glitch/features/emoji/render.ts | 403 +++--------------- .../flavours/glitch/features/emoji/types.ts | 10 +- .../glitch/features/emoji/utils.test.ts | 80 ++-- .../flavours/glitch/features/emoji/utils.ts | 16 +- 14 files changed, 327 insertions(+), 657 deletions(-) create mode 100644 app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx delete mode 100644 app/javascript/flavours/glitch/features/emoji/hooks.ts diff --git a/app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx b/app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx new file mode 100644 index 0000000000..d4f5663ae4 --- /dev/null +++ b/app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx @@ -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 & { 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 ; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomEmoji: Story = { + args: { + code: ':custom:', + }, +}; diff --git a/app/javascript/flavours/glitch/components/emoji/html.tsx b/app/javascript/flavours/glitch/components/emoji/html.tsx index b4689101d5..ee4c84f1f5 100644 --- a/app/javascript/flavours/glitch/components/emoji/html.tsx +++ b/app/javascript/flavours/glitch/components/emoji/html.tsx @@ -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; diff --git a/app/javascript/flavours/glitch/components/emoji/index.tsx b/app/javascript/flavours/glitch/components/emoji/index.tsx index b79a45289c..6ca3321452 100644 --- a/app/javascript/flavours/glitch/components/emoji/index.tsx +++ b/app/javascript/flavours/glitch/components/emoji/index.tsx @@ -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, diff --git a/app/javascript/flavours/glitch/components/html_block/html_block.stories.tsx b/app/javascript/flavours/glitch/components/html_block/html_block.stories.tsx index 9c104ba45c..6fb3206df0 100644 --- a/app/javascript/flavours/glitch/components/html_block/html_block.stories.tsx +++ b/app/javascript/flavours/glitch/components/html_block/html_block.stories.tsx @@ -7,23 +7,49 @@ const meta = { title: 'Components/HTMLBlock', component: HTMLBlock, args: { - contents: - '

Hello, world!

\n

A link

\n

This should be filtered out:

', + htmlString: `

Hello, world!

+

A link

+

This should be filtered out:

+

This also has emoji: 🖤

`, + }, + argTypes: { + extraEmojis: { + table: { + disable: true, + }, + }, + onElement: { + table: { + disable: true, + }, + }, + onAttribute: { + table: { + disable: true, + }, + }, }, render(args) { return ( // Just for visual clarity in Storybook. -
- -
+ /> ); }, + // Force Twemoji to demonstrate emoji rendering. + parameters: { + state: { + meta: { + emoji_style: 'twemoji', + }, + }, + }, } satisfies Meta; export default meta; diff --git a/app/javascript/flavours/glitch/components/html_block/index.tsx b/app/javascript/flavours/glitch/components/html_block/index.tsx index 8072937331..be7b28ec3f 100644 --- a/app/javascript/flavours/glitch/components/html_block/index.tsx +++ b/app/javascript/flavours/glitch/components/html_block/index.tsx @@ -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({ maxSize: 1000 }); - -interface HTMLBlockProps { - contents: string; - extraEmojis?: CustomEmojiMapArg; -} - -export const HTMLBlock: FC = ({ - 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[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 ; + }, +); diff --git a/app/javascript/flavours/glitch/features/emoji/emoji_picker.tsx b/app/javascript/flavours/glitch/features/emoji/emoji_picker.tsx index 4677d6115f..8daadffd0a 100644 --- a/app/javascript/flavours/glitch/features/emoji/emoji_picker.tsx +++ b/app/javascript/flavours/glitch/features/emoji/emoji_picker.tsx @@ -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`; diff --git a/app/javascript/flavours/glitch/features/emoji/hooks.ts b/app/javascript/flavours/glitch/features/emoji/hooks.ts deleted file mode 100644 index 067fb1d48b..0000000000 --- a/app/javascript/flavours/glitch/features/emoji/hooks.ts +++ /dev/null @@ -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(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'), - }; -} diff --git a/app/javascript/flavours/glitch/features/emoji/index.ts b/app/javascript/flavours/glitch/features/emoji/index.ts index dd3fe841ba..2d181085a1 100644 --- a/app/javascript/flavours/glitch/features/emoji/index.ts +++ b/app/javascript/flavours/glitch/features/emoji/index.ts @@ -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) => { const { data: message } = event; if (message === 'ready') { diff --git a/app/javascript/flavours/glitch/features/emoji/mode.ts b/app/javascript/flavours/glitch/features/emoji/mode.ts index 994881bca1..197f130b5f 100644 --- a/app/javascript/flavours/glitch/features/emoji/mode.ts +++ b/app/javascript/flavours/glitch/features/emoji/mode.ts @@ -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; diff --git a/app/javascript/flavours/glitch/features/emoji/render.test.ts b/app/javascript/flavours/glitch/features/emoji/render.test.ts index 108cf74750..05dbc388c4 100644 --- a/app/javascript/flavours/glitch/features/emoji/render.test.ts +++ b/app/javascript/flavours/glitch/features/emoji/render.test.ts @@ -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 = - '😊'; -const expectedFlagImage = - '🇪🇺'; - -function testAppState(state: Partial = {}) { - return { - locales: ['en'], - mode: EMOJI_MODE_TWEMOJI, - currentLocale: 'en', - darkTheme: false, - ...state, - } satisfies EmojiAppState; -} - -describe('emojifyElement', () => { - function testElement(text = '

Hello 😊🇪🇺!

:custom:

') { - 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('

here is just text :)

'), - 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(); + }); +}); diff --git a/app/javascript/flavours/glitch/features/emoji/render.ts b/app/javascript/flavours/glitch/features/emoji/render.ts index 5e10f9367c..574d5ef59b 100644 --- a/app/javascript/flavours/glitch/features/emoji/render.ts +++ b/app/javascript/flavours/glitch/features/emoji/render.ts @@ -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: Element, - appState: EmojiAppState, - extraEmojis: ExtraCustomEmojiMap = {}, -): Promise { - 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 { - 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({ 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 { - // 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([ - [ - EMOJI_TYPE_CUSTOM, - createLimitedCache({ log: log.extend('custom') }), - ], -]); - -function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap { - return ( - localeCacheMap.get(locale) ?? - createLimitedCache({ 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(); - const missingCustomEmoji = new Set(); - - // 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( - 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(); -}; diff --git a/app/javascript/flavours/glitch/features/emoji/types.ts b/app/javascript/flavours/glitch/features/emoji/types.ts index b9c53e0697..a940aa4d92 100644 --- a/app/javascript/flavours/glitch/features/emoji/types.ts +++ b/app/javascript/flavours/glitch/features/emoji/types.ts @@ -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 | Required; -export type EmojiStateMap = LimitedCache; - export type CustomEmojiMapArg = | ExtraCustomEmojiMap | ImmutableList @@ -64,9 +62,3 @@ export type ExtraCustomEmojiMap = Record< string, Pick >; - -export interface TwemojiBorderInfo { - hexCode: string; - hasLightBorder: boolean; - hasDarkBorder: boolean; -} diff --git a/app/javascript/flavours/glitch/features/emoji/utils.test.ts b/app/javascript/flavours/glitch/features/emoji/utils.test.ts index b9062294c4..3844c814a1 100644 --- a/app/javascript/flavours/glitch/features/emoji/utils.test.ts +++ b/app/javascript/flavours/glitch/features/emoji/utils.test.ts @@ -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(); - }); -}); diff --git a/app/javascript/flavours/glitch/features/emoji/utils.ts b/app/javascript/flavours/glitch/features/emoji/utils.ts index bb190951cf..4c6750f6e2 100644 --- a/app/javascript/flavours/glitch/features/emoji/utils.ts +++ b/app/javascript/flavours/glitch/features/emoji/utils.ts @@ -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}'; From 7330ec670b1ba96c2527530b757f9c83cecff122 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 14 Oct 2025 14:21:13 +0200 Subject: [PATCH 13/14] [Glitch] Fix WebUI mistakenly allowing to attach quotes when editing Port 156f031ed046244b26016df92dbf2c72d3b23c3d to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/actions/compose_typed.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/compose_typed.ts b/app/javascript/flavours/glitch/actions/compose_typed.ts index ec929df848..0b6b0f50de 100644 --- a/app/javascript/flavours/glitch/actions/compose_typed.ts +++ b/app/javascript/flavours/glitch/actions/compose_typed.ts @@ -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; From c4c906eb0c56692d9018eec6b86e01dfaa6563b1 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 14 Oct 2025 16:04:18 +0200 Subject: [PATCH 14/14] [Glitch] Fix error in logged-out hashtag view when remote posts require log-in Port 3232eee358134ca468b40ec3b517a15fe69b7fe6 to glitch-soc Signed-off-by: Claire --- .../features/hashtag_timeline/index.jsx | 32 +++++++++++-------- .../flavours/glitch/initial_state.ts | 4 +++ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.jsx b/app/javascript/flavours/glitch/features/hashtag_timeline/index.jsx index 48335488f0..c2c453aa37 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.jsx @@ -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 ( diff --git a/app/javascript/flavours/glitch/initial_state.ts b/app/javascript/flavours/glitch/initial_state.ts index 926cfbe1c2..06d78125bb 100644 --- a/app/javascript/flavours/glitch/initial_state.ts +++ b/app/javascript/flavours/glitch/initial_state.ts @@ -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');