diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ce5b64a99..e07121d947 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/Gemfile.lock b/Gemfile.lock
index fc3f577e08..22842a6379 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/app/javascript/entrypoints/wrapstodon.tsx b/app/javascript/entrypoints/wrapstodon.tsx
index 9fff41a133..814703f865 100644
--- a/app/javascript/entrypoints/wrapstodon.tsx
+++ b/app/javascript/entrypoints/wrapstodon.tsx
@@ -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() {
-
+
+
+
,
diff --git a/app/javascript/mastodon/actions/importer/emoji.ts b/app/javascript/mastodon/actions/importer/emoji.ts
index 36fb04b51e..db15a8a9bf 100644
--- a/app/javascript/mastodon/actions/importer/emoji.ts
+++ b/app/javascript/mastodon/actions/importer/emoji.ts
@@ -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.');
}
}
diff --git a/app/javascript/mastodon/components/account_header/styles.module.scss b/app/javascript/mastodon/components/account_header/styles.module.scss
index 4de49f79ae..48320a6ea4 100644
--- a/app/javascript/mastodon/components/account_header/styles.module.scss
+++ b/app/javascript/mastodon/components/account_header/styles.module.scss
@@ -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;
diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx
index 481057bd7d..246255dcbe 100644
--- a/app/javascript/mastodon/components/hotkeys/index.tsx
+++ b/app/javascript/mastodon/components/hotkeys/index.tsx
@@ -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 | 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(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;
diff --git a/app/javascript/mastodon/components/hotkeys/utils.ts b/app/javascript/mastodon/components/hotkeys/utils.ts
index 1430e1685b..a5f0ddd64d 100644
--- a/app/javascript/mastodon/components/hotkeys/utils.ts
+++ b/app/javascript/mastodon/components/hotkeys/utils.ts
@@ -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();
+ }
+}
diff --git a/app/javascript/mastodon/components/navigation_focus_target/index.tsx b/app/javascript/mastodon/components/navigation_focus_target/index.tsx
index f4b42926a8..1b86102fdf 100644
--- a/app/javascript/mastodon/components/navigation_focus_target/index.tsx
+++ b/app/javascript/mastodon/components/navigation_focus_target/index.tsx
@@ -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) {
diff --git a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx
index 9fc26ac525..0f2e7147ff 100644
--- a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx
+++ b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx
@@ -140,7 +140,7 @@ export const EditFieldModal = forwardRef<
const customEmojis = useCustomEmojis();
const customEmojiCodes = useMemo(
- () => Object.keys(customEmojis ?? {}),
+ () => Object.keys(customEmojis),
[customEmojis],
);
const checkField = useCallback(
diff --git a/app/javascript/mastodon/features/account_edit/styles.module.scss b/app/javascript/mastodon/features/account_edit/styles.module.scss
index 4991f8e7e5..0c8ba3b33a 100644
--- a/app/javascript/mastodon/features/account_edit/styles.module.scss
+++ b/app/javascript/mastodon/features/account_edit/styles.module.scss
@@ -41,6 +41,7 @@
.bio {
unicode-bidi: plaintext;
+ overflow-wrap: anywhere;
p:not(:last-child) {
margin-bottom: 20px;
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
index 3da7bf1812..5da6eb8609 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
@@ -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 });
});
diff --git a/app/javascript/mastodon/features/emoji/emoji_picker.tsx b/app/javascript/mastodon/features/emoji/emoji_picker.tsx
index 3661a7193b..15adf9fdc8 100644
--- a/app/javascript/mastodon/features/emoji/emoji_picker.tsx
+++ b/app/javascript/mastodon/features/emoji/emoji_picker.tsx
@@ -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 = ({
set = 'twitter',
sheetSize = 32,
@@ -24,17 +22,13 @@ export const Picker: FC = ({
...props
}) => {
const { mode } = useEmojiAppState();
- const { customCategories, customEmojis } = usePickerEmojis();
-
- if (!customEmojis) {
- return null;
- }
+ const { categories, emojis } = usePickerEmojis();
return (
) => {
- const { data: message } = event;
+ tempWorker.addEventListener(
+ 'message',
+ (event: MessageEvent) => {
+ 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');
+}
diff --git a/app/javascript/mastodon/features/emoji/mode.ts b/app/javascript/mastodon/features/emoji/mode.ts
index f7b0ea0216..32a0f2a893 100644
--- a/app/javascript/mastodon/features/emoji/mode.ts
+++ b/app/javascript/mastodon/features/emoji/mode.ts
@@ -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,
diff --git a/app/javascript/mastodon/features/emoji/picker.ts b/app/javascript/mastodon/features/emoji/picker.ts
index 7acf878f51..9a49dd66ac 100644
--- a/app/javascript/mastodon/features/emoji/picker.ts
+++ b/app/javascript/mastodon/features/emoji/picker.ts
@@ -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({ 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();
+ 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);
}
diff --git a/app/javascript/mastodon/features/emoji/render.ts b/app/javascript/mastodon/features/emoji/render.ts
index affa8e0f16..e5ac5bcc6e 100644
--- a/app/javascript/mastodon/features/emoji/render.ts
+++ b/app/javascript/mastodon/features/emoji/render.ts
@@ -109,7 +109,7 @@ export async function updateHtmlWithEmoji({
}: {
element: Element;
locale: string;
-} & Omit) {
+} & Omit) {
if (mode === EMOJI_MODE_NATIVE) {
return;
}
diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts
index 5c9dc6e5c6..b6dbad47d2 100644
--- a/app/javascript/mastodon/features/emoji/types.ts
+++ b/app/javascript/mastodon/features/emoji/types.ts
@@ -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;
diff --git a/app/javascript/mastodon/features/emoji/worker.ts b/app/javascript/mastodon/features/emoji/worker.ts
index d4f002daf6..49b7ce45f1 100644
--- a/app/javascript/mastodon/features/emoji/worker.ts
+++ b/app/javascript/mastodon/features/emoji/worker.ts
@@ -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) {
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);
}
}
diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx
index efc63ddfea..316493ac51 100644
--- a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx
+++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx
@@ -88,11 +88,11 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
- | k, |
+ k, + |
|
- | j, |
+ j, + |
|
diff --git a/app/javascript/mastodon/features/standalone/status/index.tsx b/app/javascript/mastodon/features/standalone/status/index.tsx
index a53d6e6b23..ccc3c2c5be 100644
--- a/app/javascript/mastodon/features/standalone/status/index.tsx
+++ b/app/javascript/mastodon/features/standalone/status/index.tsx
@@ -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 }) => {
-
+
+
+
diff --git a/app/javascript/mastodon/hooks/useAudioContext.ts b/app/javascript/mastodon/hooks/useAudioContext.ts
index 84acf5ac7f..2cad7ee3a5 100644
--- a/app/javascript/mastodon/hooks/useAudioContext.ts
+++ b/app/javascript/mastodon/hooks/useAudioContext.ts
@@ -18,7 +18,7 @@ export const useAudioContext = ({ audioElementRef }: AudioContextOptions) => {
const gainNodeRef = useRef();
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 {
diff --git a/app/javascript/mastodon/hooks/useCustomEmojis.ts b/app/javascript/mastodon/hooks/useCustomEmojis.ts
index 6eb3c17fbf..213b7de062 100644
--- a/app/javascript/mastodon/hooks/useCustomEmojis.ts
+++ b/app/javascript/mastodon/hooks/useCustomEmojis.ts
@@ -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);
}
diff --git a/app/javascript/mastodon/reducers/slices/emojis.ts b/app/javascript/mastodon/reducers/slices/emojis.ts
new file mode 100644
index 0000000000..28e9430c40
--- /dev/null
+++ b/app/javascript/mastodon/reducers/slices/emojis.ts
@@ -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>;
+ customCategories: Record; // { name: shortcodes[] }
+ customLoaded: boolean;
+ localesLoaded: Locale[];
+}
+
+const emojisSlice = createSlice({
+ name: 'emojis',
+ initialState: {
+ custom: {},
+ customCategories: {},
+ customLoaded: false,
+ localesLoaded: [],
+ } as EmojisState,
+ reducers: {
+ loadLocale(state, action: PayloadAction) {
+ 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();
+ },
+);
diff --git a/app/javascript/mastodon/reducers/slices/index.ts b/app/javascript/mastodon/reducers/slices/index.ts
index 8d4ecd552d..1b94687cc6 100644
--- a/app/javascript/mastodon/reducers/slices/index.ts
+++ b/app/javascript/mastodon/reducers/slices/index.ts
@@ -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,
};
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 577288ce23..790b6584cc 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3017,7 +3017,6 @@ a.account__display-name {
&__pane {
height: 100%;
- overflow: hidden;
display: flex;
justify-content: flex-end;
min-width: 285px;
diff --git a/app/models/collection.rb b/app/models/collection.rb
index 337126b145..329460b574 100644
--- a/app/models/collection.rb
+++ b/app/models/collection.rb
@@ -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?
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 9a1a366869..a8fb5e53bd 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -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 }
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index b3c58f7c00..9a099a4fd4 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -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
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 03c281c3d1..6309e1a22b 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -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:
diff --git a/config/vite/plugin-sw-chunk-paths.ts b/config/vite/plugin-sw-chunk-paths.ts
new file mode 100644
index 0000000000..bcbf468904
--- /dev/null
+++ b/config/vite/plugin-sw-chunk-paths.ts
@@ -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 };
+ },
+ };
+}
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index ffdfdbea21..dd91302030 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -17,7 +17,7 @@ module Mastodon
end
def default_prerelease
- 'rc.1'
+ 'rc.2'
end
def prerelease
diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb
index 67b51fdcea..16322aff9f 100644
--- a/spec/models/collection_spec.rb
+++ b/spec/models/collection_spec.rb
@@ -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
diff --git a/vite.config.mts b/vite.config.mts
index 87ee8b0520..81e83f4be5 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -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,