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:
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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user