Merge pull request #3535 from glitch-soc/glitch-soc/merge-4.6

Merge upstream changes up to e2b7096bd3f93afcf4d6b3e7ffb9ccdb417a9def into stable-4.6
This commit is contained in:
Claire 2026-06-16 22:14:57 +02:00 committed by GitHub
commit 1f927a8f77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 670 additions and 403 deletions

View File

@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
- **Add collections** (#37992, #37005, #37049, #37020, #37053, #37110, #37117, #37122, #37154, #37157, #37176, #37192, #37222, #37225, #37254, #37277, #37298, #37322, #37434, #37468, #37514, #37512, #37549, #37556, #37560, #37580, #37591, #37552, #37618, #37643, #37658, #37731, #37678, #37741, #37762, #37790, #37805, #37823, #37837, #37842, #37850, #37848, #37812, #37950, #37898, #37916, #37920, #37927, #37928, #37961, #37967, #37974, #37989, #37986, #38004, #38026, #38027, #38030, #38038, #38065, #38081, #38082, #38096, #38106, #38113, #38124, #38133, #38144, #38153, #38166, #38167, #38169, #38170, #38177, #38193, #38213, #38251, #38255, #38256, #38282, #38298, #38292, #38307, #38306, #38316, #38115, #38329, #38334, #38337, #38351, #38368, #38370, #38356, #38383, #38386, #38385, #38394, #38393, #38399, #38402, #38409, #38414, #38413, #38424, #38425, #38450, #38508, #38528, #38534, #38536, #38540, #38543, #38491, #38586, #38611, #38588, #38612, #38628, #38626, #38630, #38633, #38629, #38638, #38645, #38644, #38636, #38660, #38657, #38688, #38690, #38672, #38698, #38697, #38708, #38712, #38713, #38709, #38719, #38728, #38730, #38732, #38739, #38749, #38751, #38750, #38767, #38769, #38783, #38785, #38959, #38786, #38794, #38776, #38817, #38792, #38822, #38827, #38831, #38830, #38844, #38843, #38852, #38850, #38847, #38865, #38897, #38900, #38919, #38933, #38934, #38935, #38942, #38941, #38954, #38961, #38957, #38962, #38991, #39009, #39062, #39029, #39069, #39020, #39073, #39082, #39096, #39080, #39182, #39143, #39127, #37929, #38029, #39194, #39198, #39210, #39211, #39202, #39214, #39215, #39220, #39234, #39260, #39251, #39361, #39357, #39349, #39287, #39376, #39289, #39342, #38711, #39379, #39282, #39286, #39296, #39047, #39346, #39373, #39372 by @ChaosExAnima, @ClearlyClaire, @Gargron, @arte7, @diondiondion, @mjankowski, @oneiros, and @shleeable)
- **Add collections** (#37992, #37005, #37049, #37020, #37053, #37110, #37117, #37122, #37154, #37157, #37176, #37192, #37222, #37225, #37254, #37277, #37298, #37322, #37434, #37468, #37514, #37512, #37549, #37556, #37560, #37580, #37591, #37552, #37618, #37643, #37658, #37731, #37678, #37741, #37762, #37790, #37805, #37823, #37837, #37842, #37850, #37848, #37812, #37950, #37898, #37916, #37920, #37927, #37928, #37961, #37967, #37974, #37989, #37986, #38004, #38026, #38027, #38030, #38038, #38065, #38081, #38082, #38096, #38106, #38113, #38124, #38133, #38144, #38153, #38166, #38167, #38169, #38170, #38177, #38193, #38213, #38251, #38255, #38256, #38282, #38298, #38292, #38307, #38306, #38316, #38115, #38329, #38334, #38337, #38351, #38368, #38370, #38356, #38383, #38386, #38385, #38394, #38393, #38399, #38402, #38409, #38414, #38413, #38424, #38425, #38450, #38508, #38528, #38534, #38536, #38540, #38543, #38491, #38586, #38611, #38588, #38612, #38628, #38626, #38630, #38633, #38629, #38638, #38645, #38644, #38636, #38660, #38657, #38688, #38690, #38672, #38698, #38697, #38708, #38712, #38713, #38709, #38719, #38728, #38730, #38732, #38739, #38749, #38751, #38750, #38767, #38769, #38783, #38785, #38959, #38786, #38794, #38776, #38817, #38792, #38822, #38827, #38831, #38830, #38844, #38843, #38852, #38850, #38847, #38865, #38897, #38900, #38919, #38933, #38934, #38935, #38942, #38941, #38954, #38961, #38957, #38962, #38991, #39009, #39062, #39029, #39069, #39020, #39073, #39082, #39096, #39080, #39182, #39143, #39127, #37929, #38029, #39194, #39198, #39210, #39211, #39202, #39214, #39215, #39220, #39234, #39260, #39251, #39361, #39357, #39349, #39287, #39376, #39289, #39342, #38711, #39379, #39282, #39286, #39296, #39047, #39346, #39373, #39372, and #39429 by @ChaosExAnima, @ClearlyClaire, @Gargron, @arte7, @diondiondion, @mjankowski, @oneiros, and @shleeable)
- Create collections with up to 25 accounts each, then share them with others. You can read more about this feature [on our blog](https://blog.joinmastodon.org/2026/04/designing-collections/). This is based on FEP-7aa9 (Featured Collections) to be interoperable with the wider Fediverse. All the new API methods [are documented here](https://docs.joinmastodon.org/client/collections/).
- **Add email subscriptions** (#38163, #38507, #38502, #38487, #38527, #38582, #38741, #38907, #39162, #39271 by @ClearlyClaire and @Gargron)
- Admins can allow specific roles to enable email subscriptions on their profile, allowing anonymous visitors to subscribe to their posts via email.
@ -27,7 +27,7 @@ All notable changes to this project will be documented in this file.
- Add fallback attributes to notifications for new and infrequent notifications in REST API (#38832 and #38860 by @ClearlyClaire)
- This adds a [`supported_types`](https://docs.joinmastodon.org/methods/notifications/#query-parameters-1) parameter to `GET /api/v1/notifications`, `GET /api/v1/notifications/:id`, `GET /api/v2/notifications`, and `GET /api/v2/notifications/:group_key` along with a new `fallback` attribute for notifications and notification groups.
- Add support for posts in vertical languages in web UI (#37204, #38205, and #38797 by @shimon1024)
- Add `PageUp` and `PageDown` hotkeys for list navigation (#39252 by @diondiondion)
- Add `Alt` + `PageUp` and `Alt` + `PageDown` hotkeys for list navigation (#39252 and #39427 by @diondiondion)
- Add `g`+`e` keyboard shortcut to access the trending page in web UI (#38014 by @antoinecellerier)
- Add `Cmd`/`Ctrl`+`Enter` for form submissions in more text areas in web UI (#37821 by @diondiondion)
- Add support for quoting by dragging a link into the compose form in web UI (#36859 and #36896 by @ClearlyClaire and @tribela)
@ -57,7 +57,7 @@ All notable changes to this project will be documented in this file.
- Change limitation to allow posts with both media and a poll to be created (#39203, #39368, #39388 by @ClearlyClaire and @Gargron)
- Change alt text limit for media attachments to 10,000 characters (#39306 by @ClearlyClaire)
- Change pending user notification email to link directly to the pending account (#39206 by @vmstan)
- Changed emoji processing in web UI to make it less resource intensive and more robust (#39077, #39008, #39088, #38892, #38885, #38965, #38854, #38825, #38784, #38541, #37442, #37300, #37306, #37271, #37255, #37284, #37272, #37178, #37084, #37080, #37418, #39167, #39126, #39353, #39378, #39382 by @ChaosExAnima, @ClearlyClaire, @diondiondion, and @gomasy)
- Changed emoji processing in web UI to make it less resource intensive and more robust (#39077, #39008, #39088, #38892, #38885, #38965, #38854, #38825, #38784, #38541, #37442, #37300, #37306, #37271, #37255, #37284, #37272, #37178, #37084, #37080, #37418, #39167, #39126, #39353, #39378, #39382, #39402, and #39421 by @ChaosExAnima, @ClearlyClaire, @diondiondion, @gomasy, and @Hanage999)
- Change composer textarea to have a limited height to prevent column scrolling (#39268 by @diondiondion)
- Change mentions of “Mastodon gGmbH” to “Mastodon GmbH” (#39261 by @renchap)
- Change the limited profile message to be less misleading (#39231 by @mortie)
@ -102,7 +102,8 @@ All notable changes to this project will be documented in this file.
### Fixed
- Fix accessibility issues in web UI (#37250, #38006, #38033, #38188, #38230, #38252, #38257, #38285, #38293, #38362, #38387, #38459, #38796, #38801, #39098, #39111, #39120, #39129, #39133, #39134, #39144, #39145, #39149, #39164, #39165, #39169, #39181, #39335, #39305, #39331, #39356, #39350, #39358, #39360, #39325, #39270 by @ChaosExAnima and @diondiondion)
- Fix accessibility issues in web UI (#37250, #38006, #38033, #38188, #38230, #38252, #38257, #38285, #38293, #38362, #38387, #38459, #38796, #38801, #39098, #39111, #39120, #39129, #39133, #39134, #39144, #39145, #39149, #39164, #39165, #39169, #39181, #39335, #39305, #39331, #39356, #39350, #39358, #39360, #39325, #39270, #39439, #39400, and #39408 by @ChaosExAnima and @diondiondion)
- Fix error with audio player in Safari Lockdown Mode (#39397 by @Federicorao)
- Fix tiny checkboxes and radio buttons in Safari (#39332 by @diondiondion)
- Fix handling of offset in timezone list in settings (#39334 by @mjankowski)
- Fix being unable to unmark media as sensitive when "always mark media as sensitive" is enabled in web UI (#39339 by @matrix07012)
@ -110,6 +111,7 @@ All notable changes to this project will be documented in this file.
- Fix some inputs incorrectly having resize handles in Firefox (#39274 by @diondiondion)
- Fix processing some link previews where text is language-tagged (#39190 by @zunda)
- Fix error when “New trends” email is sent at the same time trends are recomputed (#39122 by @arte7)
- Fix hovercard not showing in compose column (#39430 by @diondiondion)
- Fix hover card opening even when not preceded by mouse movement in web UI (#39166, #39381 by @diondiondion)
- Fix [ominous](https://mastodon.social/@mcc/116404362104299129) "Moments remaining" timestamp in web UI (#38488 and #38689 by @ChaosExAnima and @MitarashiDango)
- Fix filters not being applied to search results in web UI (#36346 by @ClearlyClaire)

View File

@ -456,7 +456,7 @@ GEM
mutex_m (0.3.0)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.4)
net-imap (0.6.4.1)
date
net-protocol
net-ldap (0.20.0)

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>,

View File

@ -22,10 +22,6 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
await clearCache('custom');
await loadCustomEmoji();
const { reloadCustomEmojis } =
await import('@/flavours/glitch/features/emoji/picker');
await reloadCustomEmojis();
log('Custom emojis updated, reloaded cache and picker data.');
}
}

View File

@ -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;

View File

@ -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,
});
}
@ -110,8 +134,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'),
@ -189,7 +213,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;

View File

@ -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();
}
}

View File

@ -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) {

View File

@ -5,6 +5,7 @@ import { Provider as ReduxProvider } from 'react-redux';
import { importFetchedStatuses } from '@/flavours/glitch/actions/importer';
import { hydrateStore } from '@/flavours/glitch/actions/store';
import type { ApiAnnualReportResponse } from '@/flavours/glitch/api/annual_report';
import { FocusTargetProvider } from '@/flavours/glitch/components/navigation_focus_target';
import { Router } from '@/flavours/glitch/components/router';
import { WrapstodonSharedPage } from '@/flavours/glitch/features/annual_report/shared_page';
import { IntlProvider, loadLocale } from '@/flavours/glitch/locales';
@ -52,7 +53,9 @@ function loaded() {
<IntlProvider>
<ReduxProvider store={store}>
<Router>
<WrapstodonSharedPage />
<FocusTargetProvider>
<WrapstodonSharedPage />
</FocusTargetProvider>
</Router>
</ReduxProvider>
</IntlProvider>,

View File

@ -140,7 +140,7 @@ export const EditFieldModal = forwardRef<
const customEmojis = useCustomEmojis();
const customEmojiCodes = useMemo(
() => Object.keys(customEmojis ?? {}),
() => Object.keys(customEmojis),
[customEmojis],
);
const checkField = useCallback(

View File

@ -41,6 +41,7 @@
.bio {
unicode-bidi: plaintext;
overflow-wrap: anywhere;
p:not(:last-child) {
margin-bottom: 20px;

View File

@ -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 });
});

View File

@ -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}

View File

@ -1,7 +1,6 @@
import { initialState } from '@/flavours/glitch/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('@/flavours/glitch/store');
const { loadCustomEmojis, loadLocale } =
await import('@/flavours/glitch/reducers/slices/emojis');
loadLocale(userLocale);
await store.dispatch(loadCustomEmojis());
log('loaded emoji data into store');
}

View File

@ -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,

View File

@ -1,83 +1,18 @@
import { useEffect, useState } from 'react';
import type { CategoryName, CustomEmoji } from 'emoji-mart';
import { autoPlayGif } from '@/flavours/glitch/initial_state';
import {
createAppSelector,
useAppSelector,
} from '@/flavours/glitch/store/typed_functions';
import { createLimitedCache } from '@/flavours/glitch/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('@/flavours/glitch/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);
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -92,11 +92,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>

View File

@ -11,6 +11,7 @@ import {
toggleStatusSpoilers,
} from 'flavours/glitch/actions/statuses';
import { hydrateStore } from 'flavours/glitch/actions/store';
import { FocusTargetProvider } from 'flavours/glitch/components/navigation_focus_target';
import { Router } from 'flavours/glitch/components/router';
import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status';
import { useRenderSignal } from 'flavours/glitch/hooks/useRenderSignal';
@ -86,7 +87,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>

View File

@ -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 {

View File

@ -1,40 +1,25 @@
import { useEffect, useState } from 'react';
import { createAppSelector, useAppSelector } from '@/flavours/glitch/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);
}

View File

@ -0,0 +1,71 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { Locale } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/flavours/glitch/api_types/custom_emoji';
import { toSupportedLocale } from '@/flavours/glitch/features/emoji/locale';
import { createAsyncThunk } from '@/flavours/glitch/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('@/flavours/glitch/features/emoji/database');
return loadAllCustomEmoji();
},
);

View File

@ -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,
};

View File

@ -3083,7 +3083,6 @@ a.account__display-name {
&__pane {
height: 100%;
overflow: hidden;
display: flex;
justify-content: flex-end;
min-width: 285px;

View File

@ -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.');
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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) {

View File

@ -140,7 +140,7 @@ export const EditFieldModal = forwardRef<
const customEmojis = useCustomEmojis();
const customEmojiCodes = useMemo(
() => Object.keys(customEmojis ?? {}),
() => Object.keys(customEmojis),
[customEmojis],
);
const checkField = useCallback(

View File

@ -41,6 +41,7 @@
.bio {
unicode-bidi: plaintext;
overflow-wrap: anywhere;
p:not(:last-child) {
margin-bottom: 20px;

View File

@ -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 });
});

View File

@ -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}

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');
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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 {

View File

@ -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);
}

View File

@ -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();
},
);

View File

@ -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,
};

View File

@ -3017,7 +3017,6 @@ a.account__display-name {
&__pane {
height: 100%;
overflow: hidden;
display: flex;
justify-content: flex-end;
min-width: 285px;

View File

@ -33,6 +33,7 @@ class Collection < ApplicationRecord
has_many :accepted_collection_items, -> { accepted }, class_name: 'CollectionItem', inverse_of: :collection # rubocop:disable Rails/HasManyOrHasOneDependent
has_many :collection_reports, dependent: :delete_all
has_many :accounts, -> { merge(CollectionItem.pending_or_accepted) }, through: :collection_items
has_many :notifications, as: :activity, dependent: :destroy
validates :name, presence: true
validates :name, length: { maximum: 40 }, if: :local?

View File

@ -136,7 +136,7 @@ class Notification < ApplicationRecord
belongs_to :generated_annual_report, inverse_of: false
belongs_to :quote, inverse_of: :notification
belongs_to :collection_item, inverse_of: false # TODO: have an inverse?
belongs_to :collection, inverse_of: false # TODO: have an inverse?
belongs_to :collection, inverse_of: :notifications
end
validates :type, inclusion: { in: TYPES }

View File

@ -31,6 +31,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
else
{
url: frontend_asset_url('images/preview.png'),
description: I18n.t('about.default_thumbnail_description', locale: object.languages[0]),
}
end
end

View File

@ -4,6 +4,7 @@ en:
about_mastodon_html: 'The social network of the future: No ads, no corporate surveillance, ethical design, and decentralization! Own your data with Mastodon!'
contact_missing: Not set
contact_unavailable: N/A
default_thumbnail_description: Two smiling cartoon mastodons (who look like elephants) toss a paper plane between them. They're surrounded by a bright blue sky and floating planets with trees and more mastodons on them.
hosted_on: Mastodon hosted on %{domain}
title: About
accounts:

View File

@ -0,0 +1,61 @@
/* The service worker is built as a regular Vite entry point and is served from
`/sw.js` (a symlink to `packs/sw.js`), while the chunks it depends on live in
the build output directory (e.g. `/packs/`).
Browsers resolve a module's static import specifiers relative to the *URL* of
the script, not its location on disk. Because the worker is served from the
root, relative specifiers such as `./chunk-abc123.js` resolve to
`/chunk-abc123.js` and return a 404, which prevents the service worker from
installing.
This plugin rewrites those relative specifiers in the service worker output
to absolute paths pointing at the build output directory, so they resolve
correctly regardless of the URL the worker is served from.
*/
import type { Plugin, ResolvedConfig } from 'vite';
const SERVICE_WORKER_FILENAME = 'sw.js';
export function MastodonServiceWorkerChunkPaths(): Plugin {
let config: ResolvedConfig;
return {
name: 'mastodon-sw-chunk-paths',
configResolved(resolvedConfig) {
config = resolvedConfig;
},
renderChunk(code, chunk) {
if (chunk.fileName !== SERVICE_WORKER_FILENAME) {
return null;
}
// Resolve the base from the final Vite config rather than receiving it as
// an argument, so forks or other plugins that alter `base` are respected.
// Vite normalises `base` to always include a trailing slash.
const { base } = config;
// Drive the rewrite from the chunk's actual dependency list rather than a
// generic regex, so we only ever touch real (hashed) chunk specifiers and
// never accidentally match similar-looking text inside string literals.
const dependencies = [...chunk.imports, ...chunk.dynamicImports];
let rewritten = code;
for (const dependency of dependencies) {
// The worker sits at the root of the output directory, so each
// dependency is imported with a leading `./`.
rewritten = rewritten
.replaceAll(`"./${dependency}"`, `"${base}${dependency}"`)
.replaceAll(`'./${dependency}'`, `'${base}${dependency}'`);
}
if (rewritten === code) {
return null;
}
// Only import specifiers are changed, so the existing source map stays
// accurate enough; returning `null` avoids a spurious sourcemap warning.
return { code: rewritten, map: null };
},
};
}

View File

@ -17,7 +17,7 @@ module Mastodon
end
def default_prerelease
'rc.1'
'rc.2'
end
def prerelease

View File

@ -221,4 +221,19 @@ RSpec.describe Collection do
expect(subject.to_log_permalink).to eq ActivityPub::TagManager.instance.uri_for(subject)
end
end
describe '#destroy' do
let(:collection) { Fabricate(:collection) }
before do
Fabricate(:notification, activity: collection, type: :added_to_collection)
Fabricate(:notification, activity: collection, type: :collection_update)
end
it 'removes the collection and all notifications that reference it' do
expect { collection.destroy }
.to change(described_class, :count).by(-1)
.and change(Notification, :count).by(-2)
end
end
end

View File

@ -22,6 +22,7 @@ import svgr from 'vite-plugin-svgr';
import { MastodonAssetsManifest } from './config/vite/plugin-assets-manifest';
import { GlitchThemes as MastodonThemes } from './config/vite/plugin-glitch-themes';
import { MastodonNameLookup } from './config/vite/plugin-name-lookup';
import { MastodonServiceWorkerChunkPaths } from './config/vite/plugin-sw-chunk-paths';
import { MastodonServiceWorkerLocales } from './config/vite/plugin-sw-locales';
const jsRoot = path.resolve(__dirname, 'app/javascript');
@ -185,6 +186,7 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => {
MastodonThemes(),
MastodonAssetsManifest(),
MastodonServiceWorkerLocales(),
MastodonServiceWorkerChunkPaths(),
legacy({
renderLegacyChunks: false,
modernPolyfills: true,