Merge commit 'e2b7096bd3f93afcf4d6b3e7ffb9ccdb417a9def' into glitch-soc/merge-4.6

This commit is contained in:
Claire committed 2026-06-16 21:49:17 +02:00
commit 6aca68e185
33 files changed
+380 -205

No files matched your search

+4 -1
View File
@@ -5,6 +5,7 @@ import { Provider as ReduxProvider } from 'react-redux';
import { importFetchedStatuses } from '@/mastodon/actions/importer';
import { hydrateStore } from '@/mastodon/actions/store';
import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report';
import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target';
import { Router } from '@/mastodon/components/router';
import { WrapstodonSharedPage } from '@/mastodon/features/annual_report/shared_page';
import { IntlProvider, loadLocale } from '@/mastodon/locales';
@@ -52,7 +53,9 @@ function loaded() {
<IntlProvider>
<ReduxProvider store={store}>
<Router>
<WrapstodonSharedPage />
<FocusTargetProvider>
<WrapstodonSharedPage />
</FocusTargetProvider>
</Router>
</ReduxProvider>
</IntlProvider>,
@@ -22,10 +22,6 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
await clearCache('custom');
await loadCustomEmoji();
const { reloadCustomEmojis } =
await import('@/mastodon/features/emoji/picker');
await reloadCustomEmojis();
log('Custom emojis updated, reloaded cache and picker data.');
}
}
@@ -278,6 +278,7 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
font-size: 15px;
color: var(--color-text-primary);
unicode-bidi: plaintext;
overflow-wrap: anywhere;
p {
margin-bottom: 20px;
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { normalizeKey, isKeyboardEvent } from './utils';
import { normalizeKey, isKeyboardEvent, matchesKeyCode } from './utils';
/**
* In case of multiple hotkeys matching the pressed key(s),
@@ -55,6 +55,30 @@ function any(...keys: string[]): KeyMatcher {
});
}
/**
* Matches any matcher function out of those provided
*/
function anyMatcher(...matchers: KeyMatcher[]): KeyMatcher {
return (event) => {
let match: ReturnType<KeyMatcher> | undefined;
for (const matcher of matchers) {
const matcherResult = matcher(event);
if (matcherResult.isMatch) {
match = matcherResult;
break;
}
}
return (
match ?? {
isMatch: false,
priority: hotkeyPriority.singleKey,
}
);
};
}
/**
* Matches a single key combined with the option/alt modifier
*/
@@ -62,7 +86,7 @@ function optionPlus(key: string): KeyMatcher {
return (event) => ({
// Matching against event.code here as alt combos are often
// mapped to other characters
isMatch: event.altKey && event.code === `Key${key.toUpperCase()}`,
isMatch: event.altKey && matchesKeyCode(key, event.code),
priority: hotkeyPriority.combo,
});
}
@@ -109,8 +133,8 @@ const hotkeyMatcherMap = {
mention: just('m'),
open: any('enter', 'o'),
openProfile: just('p'),
moveDown: any('j', 'pagedown'),
moveUp: any('k', 'pageup'),
moveDown: anyMatcher(just('j'), optionPlus('pagedown')),
moveUp: anyMatcher(just('k'), optionPlus('pageup')),
moveToTop: just('0'),
toggleHidden: just('x'),
toggleSensitive: just('h'),
@@ -188,7 +212,7 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
if (shouldHandleEvent) {
const matchCandidates: {
// A candidate will be have an undefined handler if it's matched,
// A candidate can have an undefined handler if it's matched,
// but handled in a parent component rather than this one.
handler: HandlerFunction | undefined;
priority: number;
@@ -2,6 +2,9 @@ export function isKeyboardEvent(event: Event): event is KeyboardEvent {
return 'key' in event;
}
/**
* Normalises key values to consistent lowercase strings
*/
export function normalizeKey(key: string): string {
const lowerKey = key.toLowerCase();
@@ -27,3 +30,16 @@ export function normalizeKey(key: string): string {
return lowerKey;
}
}
/**
* Compare whether a key matches an `event.code` value,
* with support for single-letter keys (which are otherwise
* represented with a `Key` prefix, e.g. `m` is `KeyM`).
*/
export function matchesKeyCode(key: string, code: string) {
if (key.length === 1) {
return code === `Key${key.toUpperCase()}`;
} else {
return code.toLowerCase() === key.toLowerCase();
}
}
@@ -55,13 +55,11 @@ export const FocusTargetProvider: React.FC<{
| null
>(null);
const {
pathname,
search,
state = {},
} = useLocation<{ focusTarget?: FocusTarget } | undefined>();
const { pathname, search, state } = useLocation<{
focusTarget?: FocusTarget;
} | null>();
const { focusTarget } = state;
const { focusTarget } = state ?? {};
useLayoutEffect(() => {
// We never want to set focus on page load, so we keep
@@ -102,15 +100,9 @@ export const FocusTargetProvider: React.FC<{
export function useFocusOnNavigation(targetName?: string) {
const focusTargetRef = useContext(FocusTargetContext);
if (focusTargetRef === null) {
throw Error(
'useFocusTargetContext must be used inside of a FocusTargetProvider',
);
}
return useCallback(
(element: HTMLElement | null) => {
const focusTarget = focusTargetRef.current;
const focusTarget = focusTargetRef?.current;
// Bail out if focusTarget was set to `false`
if (!element || !focusTarget) {
@@ -140,7 +140,7 @@ export const EditFieldModal = forwardRef<
const customEmojis = useCustomEmojis();
const customEmojiCodes = useMemo(
() => Object.keys(customEmojis ?? {}),
() => Object.keys(customEmojis),
[customEmojis],
);
const checkField = useCallback(
@@ -41,6 +41,7 @@
.bio {
unicode-bidi: plaintext;
overflow-wrap: anywhere;
p:not(:last-child) {
margin-bottom: 20px;
@@ -323,10 +323,7 @@ class EmojiPickerDropdown extends PureComponent {
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
void EmojiMart.loadCustomEmojiData().then(() => {
this.setState({ loading: false });
});
this.setState({ loading: false });
}).catch(() => {
this.setState({ loading: false, active: false });
});
@@ -13,8 +13,6 @@ import { usePickerEmojis } from './picker';
const backgroundImageFnDefault = () => `${assetHost}/emoji/sheet_16_0.png`;
export { fetchCustomEmojiData as loadCustomEmojiData } from './picker';
export const Picker: FC<PickerProps> = ({
set = 'twitter',
sheetSize = 32,
@@ -24,17 +22,13 @@ export const Picker: FC<PickerProps> = ({
...props
}) => {
const { mode } = useEmojiAppState();
const { customCategories, customEmojis } = usePickerEmojis();
if (!customEmojis) {
return null;
}
const { categories, emojis } = usePickerEmojis();
return (
<PickerRaw
data={EmojiData}
custom={customEmojis}
include={customCategories}
custom={emojis}
include={categories}
set={set}
sheetSize={sheetSize}
sheetColumns={sheetColumns}
+41 -20
View File
@@ -1,7 +1,6 @@
import { initialState } from '@/mastodon/initial_state';
import { toSupportedLocale } from './locale';
import { reloadCustomEmojis } from './picker';
import type { EmojiWorkerMessage } from './types';
import { emojiLogger } from './utils';
@@ -39,27 +38,37 @@ export async function initializeEmoji() {
void fallbackLoad();
}, WORKER_TIMEOUT);
tempWorker.addEventListener('message', (event: MessageEvent<string>) => {
const { data: message } = event;
tempWorker.addEventListener(
'message',
(event: MessageEvent<EmojiWorkerMessage>) => {
const { data: message } = event;
worker ??= tempWorker;
clearTimeout(timeoutId);
worker ??= tempWorker;
clearTimeout(timeoutId);
if (message !== 'ready') {
workerLog(message);
return;
}
const { type } = message;
if (type === 'log') {
workerLog(message.message);
} else if (type === 'done' && message.storeName === 'custom') {
void loadEmojisToStore();
}
const debugValue = localStorage.getItem('debug');
if (debugValue) {
messageWorker({ type: 'debug', debugValue });
}
if (type !== 'ready') {
return; // Exit for other messages.
}
workerLog('loading data');
messageWorker(userLocale);
messageWorker('custom');
messageWorker('shortcodes');
});
const debugValue = localStorage.getItem('debug');
if (debugValue) {
messageWorker({ type: 'debug', debugValue });
}
workerLog('loading data');
messageWorker(userLocale);
messageWorker('custom');
messageWorker('shortcodes');
void loadEmojisToStore();
},
);
}
async function fallbackLoad() {
@@ -71,8 +80,8 @@ async function fallbackLoad() {
const customEmojis = await importCustomEmojiData();
if (customEmojis && customEmojis.length > 0) {
log('loaded %d custom emojis', customEmojis.length);
await reloadCustomEmojis();
}
const shortcodes = await importLegacyShortcodes();
if (shortcodes?.length) {
log('loaded %d legacy shortcodes', shortcodes.length);
@@ -82,6 +91,7 @@ async function fallbackLoad() {
if (emojis) {
log('loaded %d emojis to locale %s', emojis.length, userLocale);
}
await loadEmojisToStore();
}
export async function loadCustomEmoji() {
@@ -92,9 +102,9 @@ export async function loadCustomEmoji() {
const emojis = await importCustomEmojiData();
if (emojis && emojis.length > 0) {
log('loaded %d custom emojis', emojis.length);
await reloadCustomEmojis();
}
}
await loadEmojisToStore();
}
function messageWorker(data: EmojiWorkerMessage | string) {
@@ -110,3 +120,14 @@ function messageWorker(data: EmojiWorkerMessage | string) {
worker.postMessage(data);
}
}
async function loadEmojisToStore() {
const { store } = await import('@/mastodon/store');
const { loadCustomEmojis, loadLocale } =
await import('@/mastodon/reducers/slices/emojis');
loadLocale(userLocale);
await store.dispatch(loadCustomEmojis());
log('loaded emoji data into store');
}
@@ -27,7 +27,6 @@ export function useEmojiAppState(): EmojiAppState {
return {
currentLocale: locale,
locales: [locale],
mode,
darkTheme: isDarkMode(),
assetHost,
@@ -64,7 +63,6 @@ export function getEmojiAppState(): EmojiAppState {
return {
currentLocale,
locales: [currentLocale],
mode: determineEmojiMode(emojiStyle),
darkTheme: isDarkMode(),
assetHost,
@@ -1,83 +1,18 @@
import { useEffect, useState } from 'react';
import type { CategoryName, CustomEmoji } from 'emoji-mart';
import { autoPlayGif } from '@/mastodon/initial_state';
import {
createAppSelector,
useAppSelector,
} from '@/mastodon/store/typed_functions';
import { createLimitedCache } from '@/mastodon/utils/cache';
import { emojiLogger } from './utils';
const log = emojiLogger('picker');
let customEmojis: CustomEmoji[] | null = null;
let customCategories = [
'recent',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
] as CategoryName[];
const searchCache = createLimitedCache<LegacyEmoji[]>({ maxSize: 10, log });
export async function fetchCustomEmojiData() {
if (customEmojis !== null) {
return customEmojis;
}
const { loadAllCustomEmoji } = await import('./database');
const emojisRaw = await loadAllCustomEmoji();
// If it returns null then custom emojis aren't even loaded yet.
if (emojisRaw === null) {
return [];
}
// If it's empty, then they are loaded but there aren't any.
if (emojisRaw.length === 0) {
customEmojis = [];
return customEmojis;
}
const categories = new Set(['custom']);
const emojis = [];
for (const emoji of emojisRaw) {
const name = emoji.shortcode.replaceAll(':', '');
emojis.push({
name,
id: name,
custom: true,
short_names: [name],
imageUrl: autoPlayGif ? emoji.url : emoji.static_url,
customCategory: emoji.category,
});
if (emoji.category) {
categories.add(`custom-${emoji.category}`);
}
}
customEmojis = emojis.toSorted((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
customCategories = customCategories.toSpliced(
1,
0,
...(Array.from(categories).toSorted() as CategoryName[]),
);
log(
'loaded %d custom emojis in %d categories',
customEmojis.length,
categories.size,
);
return customEmojis;
}
type LegacyEmoji =
| { id: string; custom?: false; native: string }
| {
@@ -85,16 +20,6 @@ type LegacyEmoji =
custom: true;
};
export async function reloadCustomEmojis() {
customEmojis = null;
const { loadEmojisIntoCache } =
await import('@/mastodon/hooks/useCustomEmojis');
await Promise.all([fetchCustomEmojiData(), loadEmojisIntoCache()]);
searchCache.clear();
}
// Replicates the old legacy search function.
export async function emojiMartSearch(
token: string,
@@ -127,19 +52,66 @@ export async function emojiMartSearch(
return legacyResults;
}
export function usePickerEmojis() {
const [, setLoaded] = useState(customEmojis !== null);
const defaultCategories = [
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
] as CategoryName[];
useEffect(() => {
if (customEmojis === null) {
void fetchCustomEmojiData().then(() => {
setLoaded(true);
});
const selectPickerData = createAppSelector(
[(state) => state.emojis.custom, (state) => state.emojis.customCategories],
(emojis, categories) => {
// Create a map of shortcode to category name.
const categoryMap = new Map<string, string>();
for (const category in categories) {
const catEmojis = categories[category];
if (!catEmojis?.length) {
continue;
}
for (const shortcode of catEmojis) {
categoryMap.set(shortcode, category);
}
}
}, []);
return {
customEmojis,
customCategories,
};
const customEmojis: CustomEmoji[] = [];
for (const shortcode in emojis) {
const emoji = emojis[shortcode];
if (!emoji) {
continue;
}
customEmojis.push({
name: shortcode,
id: shortcode,
custom: true,
short_names: [shortcode],
imageUrl: autoPlayGif ? emoji.url : emoji.static_url,
customCategory: categoryMap.get(shortcode),
} as CustomEmoji);
}
searchCache.clear();
log('regenerated the picker data');
return {
emojis: customEmojis,
categories: [
'recent',
'custom',
...Object.keys(categories)
.toSorted()
.map((category) => `custom-${category}`),
...defaultCategories,
] as CategoryName[],
};
},
);
export function usePickerEmojis() {
return useAppSelector(selectPickerData);
}
@@ -109,7 +109,7 @@ export async function updateHtmlWithEmoji({
}: {
element: Element;
locale: string;
} & Omit<EmojiAppState, 'currentLocale' | 'locales'>) {
} & Omit<EmojiAppState, 'currentLocale'>) {
if (mode === EMOJI_MODE_NATIVE) {
return;
}
@@ -28,7 +28,6 @@ export type CacheKey =
| LocaleWithShortcodes;
export interface EmojiAppState {
locales: Locale[];
currentLocale: Locale;
mode: EmojiMode;
darkTheme: boolean;
@@ -82,10 +81,20 @@ export type ExtraCustomEmojiMap = Record<
>;
export type EmojiWorkerMessage =
| { type: 'ready' }
| {
type: 'load';
storeName: string;
}
| {
type: 'done';
storeName: string;
importCount: number;
}
| {
type: 'log';
message: string;
}
| {
type: 'debug';
debugValue: string;
@@ -9,13 +9,13 @@ import {
import type { EmojiWorkerMessage } from './types';
addEventListener('message', handleMessage);
self.postMessage('ready'); // After the worker is ready, notify the main thread
self.postMessage({ type: 'ready' } satisfies EmojiWorkerMessage); // After the worker is ready, notify the main thread
function handleMessage(event: MessageEvent<EmojiWorkerMessage>) {
const { data } = event;
if (data.type === 'debug') {
debug.enable(data.debugValue);
} else {
} else if (data.type === 'load') {
void loadData(data.storeName);
}
}
@@ -31,6 +31,10 @@ async function loadData(storeName: string) {
}
if (importCount) {
self.postMessage(`loaded ${importCount} emojis into ${storeName}`);
self.postMessage({
type: 'done',
storeName,
importCount,
} satisfies EmojiWorkerMessage);
}
}
@@ -88,11 +88,11 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
</tr>
<tr>
<td><kbd>k</kbd>, <FormattedMessage id='keyboard_shortcuts.keys.page_up' defaultMessage='Page Up' tagName='kbd' /></td>
<td><kbd>k</kbd>, <FormattedMessage id='keyboard_shortcuts.keys.alt' defaultMessage='Alt' tagName='kbd' />+<FormattedMessage id='keyboard_shortcuts.keys.page_up' defaultMessage='Page Up' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
</tr>
<tr>
<td><kbd>j</kbd>, <FormattedMessage id='keyboard_shortcuts.keys.page_down' defaultMessage='Page Down' tagName='kbd' /></td>
<td><kbd>j</kbd>, <FormattedMessage id='keyboard_shortcuts.keys.alt' defaultMessage='Alt' tagName='kbd' />+<FormattedMessage id='keyboard_shortcuts.keys.page_down' defaultMessage='Page Down' tagName='kbd' /></td>
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
</tr>
<tr>
@@ -8,6 +8,7 @@ import { Provider } from 'react-redux';
import { fetchStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
import { hydrateStore } from 'mastodon/actions/store';
import { FocusTargetProvider } from 'mastodon/components/navigation_focus_target';
import { Router } from 'mastodon/components/router';
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
import { useRenderSignal } from 'mastodon/hooks/useRenderSignal';
@@ -79,7 +80,9 @@ export const Status: React.FC<{ id: string }> = ({ id }) => {
<IntlProvider>
<Provider store={store}>
<Router>
<Embed id={id} />
<FocusTargetProvider>
<Embed id={id} />
</FocusTargetProvider>
</Router>
</Provider>
</IntlProvider>
@@ -18,7 +18,7 @@ export const useAudioContext = ({ audioElementRef }: AudioContextOptions) => {
const gainNodeRef = useRef<GainNode>();
useEffect(() => {
if (!audioElementRef.current) {
if (!audioElementRef.current || typeof AudioContext === 'undefined') {
return;
}
@@ -43,13 +43,17 @@ export const useAudioContext = ({ audioElementRef }: AudioContextOptions) => {
}, [audioElementRef]);
const playAudio = useCallback(() => {
void audioElementRef.current?.play();
void audioContextRef.current?.resume();
if (audioContextRef.current && audioElementRef.current) {
void audioElementRef.current.play();
void audioContextRef.current.resume();
}
}, [audioElementRef]);
const pauseAudio = useCallback(() => {
audioElementRef.current?.pause();
void audioContextRef.current?.suspend();
if (audioContextRef.current && audioElementRef.current) {
audioElementRef.current.pause();
void audioContextRef.current.suspend();
}
}, [audioElementRef]);
return {
@@ -1,40 +1,25 @@
import { useEffect, useState } from 'react';
import { createAppSelector, useAppSelector } from '@/mastodon/store';
import type { ExtraCustomEmojiMap } from '../features/emoji/types';
import { emojiLogger } from '../features/emoji/utils';
let emojis: ExtraCustomEmojiMap | null = null;
const log = emojiLogger('useCustomEmojis');
const selectCustomEmojis = createAppSelector(
[(state) => state.emojis.custom],
(custom) => {
const emojis: ExtraCustomEmojiMap = {};
for (const shortcode in custom) {
const emoji = custom[shortcode];
if (!emoji) {
continue;
}
emojis[shortcode] = {
shortcode,
...emoji,
};
}
return emojis;
},
);
export function useCustomEmojis() {
const [, setLoaded] = useState(emojis !== null);
useEffect(() => {
if (!emojis) {
void loadEmojisIntoCache().then(() => {
setLoaded(true);
});
}
}, []);
return emojis;
}
export async function loadEmojisIntoCache() {
const { loadAllCustomEmoji } = await import('../features/emoji/database');
const emojisRaw = await loadAllCustomEmoji();
if (emojisRaw === null) {
log('Custom emojis not loaded yet');
return;
}
emojis = {};
for (const emoji of emojisRaw) {
emojis[emoji.shortcode] = {
url: emoji.url,
shortcode: emoji.shortcode,
static_url: emoji.static_url,
};
}
log('Loaded %d custom emojis into cache', Object.keys(emojis).length);
return useAppSelector(selectCustomEmojis);
}
@@ -0,0 +1,71 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { Locale } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { toSupportedLocale } from '@/mastodon/features/emoji/locale';
import { createAsyncThunk } from '@/mastodon/store/typed_functions';
interface EmojisState {
custom: Record<string, Pick<ApiCustomEmojiJSON, 'url' | 'static_url'>>;
customCategories: Record<string, string[]>; // { name: shortcodes[] }
customLoaded: boolean;
localesLoaded: Locale[];
}
const emojisSlice = createSlice({
name: 'emojis',
initialState: {
custom: {},
customCategories: {},
customLoaded: false,
localesLoaded: [],
} as EmojisState,
reducers: {
loadLocale(state, action: PayloadAction<string>) {
const locale = toSupportedLocale(action.payload);
if (!state.localesLoaded.includes(locale)) {
state.localesLoaded.push(locale);
}
},
},
extraReducers(builder) {
builder.addAsyncThunk(loadCustomEmojis, {
fulfilled(state, action) {
if (!action.payload?.length) {
return;
}
for (const emoji of action.payload) {
const { shortcode, category, url, static_url } = emoji;
state.custom[shortcode] = {
url,
static_url,
};
if (category) {
state.customCategories[category] ??= [];
if (!state.customCategories[category].includes(shortcode)) {
state.customCategories[category].push(shortcode);
}
}
state.customLoaded = true;
}
},
});
},
});
export const emojis = emojisSlice.reducer;
export const { loadLocale } = emojisSlice.actions;
export const loadCustomEmojis = createAsyncThunk(
`${emojisSlice.name}/loadCustomEmojis`,
async () => {
const { loadAllCustomEmoji } =
await import('@/mastodon/features/emoji/database');
return loadAllCustomEmoji();
},
);
@@ -1,9 +1,11 @@
import { annualReport } from './annual_report';
import { collections } from './collections';
import { emojis } from './emojis';
import { profileEdit } from './profile_edit';
export const sliceReducers = {
annualReport,
collections,
emojis,
profileEdit,
};
@@ -3017,7 +3017,6 @@ a.account__display-name {
&__pane {
height: 100%;
overflow: hidden;
display: flex;
justify-content: flex-end;
min-width: 285px;