Merge commit '009275e66b55729f754da7b4c814b37b67dc76dc' into glitch-soc/merge-upstream

Conflicts:
- `app/models/form/admin_settings.rb`:
  Upstream added a method right next to glitch-soc-only methods.
  Added upstream's method.
- `app/views/settings/preferences/appearance/show.html.haml`:
  Upstream changed a line right next to a glitch-soc addition.
  Changed the code as upstream did.
This commit is contained in:
Claire committed 2025-12-17 18:27:14 +01:00
commit 84feffd6e6
168 files changed
+2012 -1303

No files matched your search

@@ -1,4 +1,11 @@
import { useCallback, useState, useRef, useEffect, useMemo } from 'react';
import {
useCallback,
useState,
useRef,
useEffect,
useMemo,
useId,
} from 'react';
import {
defineMessages,
@@ -432,12 +439,17 @@ export const Search: React.FC<{
switch (e.key) {
case 'Escape':
e.preventDefault();
unfocus();
searchInputRef.current?.focus();
setExpanded(false);
break;
case 'ArrowDown':
e.preventDefault();
if (!expanded) {
setExpanded(true);
}
if (navigableOptions.length > 0) {
setSelectedOption(
Math.min(selectedOption + 1, navigableOptions.length - 1),
@@ -476,10 +488,10 @@ export const Search: React.FC<{
break;
}
},
[unfocus, navigableOptions, selectedOption, submit, value],
[expanded, navigableOptions, selectedOption, submit, value],
);
const handleFocus = useCallback(() => {
const handleInputFocus = useCallback(() => {
setExpanded(true);
setSelectedOption(-1);
@@ -495,10 +507,16 @@ export const Search: React.FC<{
}
}, [setExpanded, setSelectedOption, singleColumn]);
const handleBlur = useCallback(() => {
const handleInputBlur = useCallback(() => {
setSelectedOption(-1);
}, [setSelectedOption]);
const getOptionFocusHandler = useCallback((index: number) => {
return () => {
setSelectedOption(index);
};
}, []);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
@@ -526,6 +544,8 @@ export const Search: React.FC<{
return () => null;
}, [expanded]);
const searchOptionsHeading = useId();
return (
<form ref={formRef} className={classNames('search', { active: expanded })}>
<input
@@ -541,13 +561,20 @@ export const Search: React.FC<{
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
<ClearButton hasValue={hasValue} onClick={handleClear} />
<div className='search__popout' tabIndex={-1}>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<div
className='search__popout'
role='dialog'
tabIndex={-1}
aria-labelledby={searchOptionsHeading}
onKeyDown={handleKeyDown}
>
{!hasValue && (
<>
<h4>
@@ -565,6 +592,7 @@ export const Search: React.FC<{
tabIndex={0}
role='button'
onMouseDown={action}
onFocus={getOptionFocusHandler(i)}
className={classNames(
'search__popout__menu__item search__popout__menu__item--flex',
{ selected: selectedOption === i },
@@ -606,6 +634,7 @@ export const Search: React.FC<{
<button
key={key}
onMouseDown={action}
onFocus={getOptionFocusHandler(i)}
className={classNames('search__popout__menu__item', {
selected: selectedOption === i,
})}
@@ -618,7 +647,7 @@ export const Search: React.FC<{
</>
)}
<h4>
<h4 id={searchOptionsHeading}>
<FormattedMessage
id='search_popout.options'
defaultMessage='Search options'
@@ -627,20 +656,22 @@ export const Search: React.FC<{
{searchEnabled && signedIn ? (
<div className='search__popout__menu'>
{searchOptions.map(({ key, label, action }, i) => (
<button
key={key}
onMouseDown={action}
className={classNames('search__popout__menu__item', {
selected:
selectedOption ===
(quickActions.length || recent.length) + i,
})}
type='button'
>
{label}
</button>
))}
{searchOptions.map(({ key, label, action }, i) => {
const currentIndex = (quickActions.length || recent.length) + i;
return (
<button
key={key}
onMouseDown={action}
onFocus={getOptionFocusHandler(currentIndex)}
className={classNames('search__popout__menu__item', {
selected: selectedOption === currentIndex,
})}
type='button'
>
{label}
</button>
);
})}
</div>
) : (
<div className='search__popout__menu__message'>
@@ -43,11 +43,26 @@ describe('emoji database', () => {
describe('putCustomEmojiData', () => {
test('loads custom emoji into indexedDB', async () => {
const { db } = await testGet();
await putCustomEmojiData([customEmojiFactory()]);
await putCustomEmojiData({ emojis: [customEmojiFactory()] });
await expect(db.get('custom', 'custom')).resolves.toEqual(
customEmojiFactory(),
);
});
test('clears existing custom emoji if specified', async () => {
const { db } = await testGet();
await putCustomEmojiData({
emojis: [customEmojiFactory({ shortcode: 'emoji1' })],
});
await putCustomEmojiData({
emojis: [customEmojiFactory({ shortcode: 'emoji2' })],
clear: true,
});
await expect(db.get('custom', 'emoji1')).resolves.toBeUndefined();
await expect(db.get('custom', 'emoji2')).resolves.toEqual(
customEmojiFactory({ shortcode: 'emoji2' }),
);
});
});
describe('putLegacyShortcodes', () => {
@@ -161,11 +161,26 @@ export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
await trx.done;
}
export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
export async function putCustomEmojiData({
emojis,
clear = false,
}: {
emojis: CustomEmojiData[];
clear?: boolean;
}) {
const db = await loadDB();
const trx = db.transaction('custom', 'readwrite');
// When importing from the API, clear everything first.
if (clear) {
await trx.store.clear();
log('Cleared existing custom emojis in database');
}
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
await trx.done;
log('Imported %d custom emojis into database', emojis.length);
}
export async function putLegacyShortcodes(shortcodes: ShortcodesDataset) {
@@ -188,6 +203,13 @@ export async function putLatestEtag(etag: string, localeString: string) {
await db.put('etags', etag, locale);
}
export async function clearEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
await db.delete('etags', locale);
log('Cleared etag for %s', locale);
}
export async function loadEmojiByHexcode(
hexcode: string,
localeString: string,
+46 -29
View File
@@ -3,7 +3,6 @@ import type { Locale } from 'emojibase';
import { initialState } from '@/mastodon/initial_state';
import type { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
import { importLegacyShortcodes, localeToShortcodesPath } from './loader';
import { toSupportedLocale } from './locale';
import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
@@ -16,48 +15,54 @@ let worker: Worker | null = null;
const log = emojiLogger('index');
const WORKER_TIMEOUT = 1_000; // 1 second
// This is too short, but better to fallback quickly than wait.
const WORKER_TIMEOUT = 1_000;
export function initializeEmoji() {
log('initializing emojis');
// Create a temp worker, and assign it to the module-level worker once we know it's ready.
let tempWorker: Worker | null = null;
if (!worker && 'Worker' in window) {
try {
worker = new EmojiWorker();
tempWorker = new EmojiWorker();
} catch (err) {
console.warn('Error creating web worker:', err);
}
}
if (worker) {
const timeoutId = setTimeout(() => {
log('worker is not ready after timeout');
worker = null;
void fallbackLoad();
}, WORKER_TIMEOUT);
worker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
if (message === 'ready') {
log('worker ready, loading data');
clearTimeout(timeoutId);
messageWorker('custom');
messageWorker('shortcodes');
void loadEmojiLocale(userLocale);
} else {
log('got worker message: %s', message);
}
});
} else {
if (!tempWorker) {
void fallbackLoad();
return;
}
const timeoutId = setTimeout(() => {
log('worker is not ready after timeout');
void fallbackLoad();
}, WORKER_TIMEOUT);
tempWorker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
worker ??= tempWorker;
if (message === 'ready') {
log('worker ready, loading data');
clearTimeout(timeoutId);
messageWorker('shortcodes');
void loadCustomEmoji();
void loadEmojiLocale(userLocale);
} else {
log('got worker message: %s', message);
}
});
}
async function fallbackLoad() {
log('falling back to main thread for loading');
const { importCustomEmojiData } = await import('./loader');
const emojis = await importCustomEmojiData();
if (emojis) {
log('loaded %d custom emojis', emojis.length);
}
await loadCustomEmoji();
const { importLegacyShortcodes } = await import('./loader');
const shortcodes = await importLegacyShortcodes();
if (shortcodes.length) {
log('loaded %d legacy shortcodes', shortcodes.length);
@@ -67,11 +72,11 @@ async function fallbackLoad() {
async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
const { importEmojiData, localeToEmojiPath: localeToPath } =
const { importEmojiData, localeToEmojiPath, localeToShortcodesPath } =
await import('./loader');
if (worker) {
const path = await localeToPath(locale);
const path = await localeToEmojiPath(locale);
const shortcodesPath = await localeToShortcodesPath(locale);
log('asking worker to load locale %s from %s', locale, path);
messageWorker(locale, path, shortcodesPath);
@@ -83,6 +88,18 @@ async function loadEmojiLocale(localeString: string) {
}
}
export async function loadCustomEmoji() {
if (worker) {
messageWorker('custom');
} else {
const { importCustomEmojiData } = await import('./loader');
const emojis = await importCustomEmojiData();
if (emojis && emojis.length > 0) {
log('loaded %d custom emojis', emojis.length);
}
}
}
function messageWorker(
locale: typeof EMOJI_TYPE_CUSTOM | typeof EMOJI_DB_NAME_SHORTCODES,
): void;
@@ -76,7 +76,7 @@ export async function importCustomEmojiData() {
if (!emojis) {
return;
}
await putCustomEmojiData(emojis);
await putCustomEmojiData({ emojis, clear: true });
return emojis;
}
@@ -83,12 +83,8 @@ describe('stringToEmojiState', () => {
});
});
test('returns custom emoji state for valid custom emoji', () => {
expect(stringToEmojiState(':smile:')).toEqual({
type: 'custom',
code: 'smile',
data: undefined,
});
test('returns null for custom emoji without data', () => {
expect(stringToEmojiState(':smile:')).toBeNull();
});
test('returns custom emoji state with data when provided', () => {
@@ -108,7 +104,6 @@ describe('stringToEmojiState', () => {
test('returns null for invalid emoji strings', () => {
expect(stringToEmojiState('notanemoji')).toBeNull();
expect(stringToEmojiState(':invalid-emoji:')).toBeNull();
});
});
@@ -142,21 +137,13 @@ describe('loadEmojiDataToState', () => {
});
});
test('loads custom emoji data into state', async () => {
const dbCall = vi
.spyOn(db, 'loadCustomEmojiByShortcode')
.mockResolvedValueOnce(customEmojiFactory());
test('returns null for custom emoji without data', async () => {
const customState = {
type: 'custom',
code: 'smile',
} as const satisfies EmojiStateCustom;
const result = await loadEmojiDataToState(customState, 'en');
expect(dbCall).toHaveBeenCalledWith('smile');
expect(result).toEqual({
type: 'custom',
code: 'smile',
data: customEmojiFactory(),
});
expect(result).toBeNull();
});
test('loads unicode data using legacy shortcode', async () => {
@@ -194,16 +181,6 @@ describe('loadEmojiDataToState', () => {
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 satisfies EmojiStateCustom;
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')
@@ -5,7 +5,6 @@ import {
EMOJI_TYPE_CUSTOM,
} from './constants';
import {
loadCustomEmojiByShortcode,
loadEmojiByHexcode,
loadLegacyShortcodesByShortcode,
LocaleNotLoadedError,
@@ -80,7 +79,7 @@ export function tokenizeText(text: string): TokenizedText {
export function stringToEmojiState(
code: string,
customEmoji: ExtraCustomEmojiMap = {},
): EmojiState | null {
): EmojiStateUnicode | Required<EmojiStateCustom> | null {
if (isUnicodeEmoji(code)) {
return {
type: EMOJI_TYPE_UNICODE,
@@ -90,11 +89,13 @@ export function stringToEmojiState(
if (isCustomEmoji(code)) {
const shortCode = code.slice(1, -1);
return {
type: EMOJI_TYPE_CUSTOM,
code: shortCode,
data: customEmoji[shortCode],
};
if (customEmoji[shortCode]) {
return {
type: EMOJI_TYPE_CUSTOM,
code: shortCode,
data: customEmoji[shortCode],
};
}
}
return null;
@@ -115,33 +116,29 @@ export async function loadEmojiDataToState(
return state;
}
// Don't try to load data for custom emoji.
if (state.type === EMOJI_TYPE_CUSTOM) {
return null;
}
// First, try to load the data from IndexedDB.
try {
const legacyCode = await loadLegacyShortcodesByShortcode(state.code);
// This is duplicative, but that's because TS can't distinguish the state type easily.
if (state.type === EMOJI_TYPE_UNICODE || legacyCode) {
const data = await loadEmojiByHexcode(
legacyCode?.hexcode ?? state.code,
locale,
);
if (data) {
return {
...state,
type: EMOJI_TYPE_UNICODE,
data,
// TODO: Use CLDR shortcodes when the picker supports them.
shortcode: legacyCode?.shortcodes.at(0),
};
}
} else {
const data = await loadCustomEmojiByShortcode(state.code);
if (data) {
return {
...state,
data,
};
}
const data = await loadEmojiByHexcode(
legacyCode?.hexcode ?? state.code,
locale,
);
if (data) {
return {
...state,
type: EMOJI_TYPE_UNICODE,
data,
// TODO: Use CLDR shortcodes when the picker supports them.
shortcode: legacyCode?.shortcodes.at(0),
};
}
// If not found, assume it's not an emoji and return null.
log(
'Could not find emoji %s of type %s for locale %s',