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/flavours/glitch/actions/importer/emoji.ts b/app/javascript/flavours/glitch/actions/importer/emoji.ts index beccbcebae..8bc6bbbcb3 100644 --- a/app/javascript/flavours/glitch/actions/importer/emoji.ts +++ b/app/javascript/flavours/glitch/actions/importer/emoji.ts @@ -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.'); } } diff --git a/app/javascript/flavours/glitch/components/account_header/styles.module.scss b/app/javascript/flavours/glitch/components/account_header/styles.module.scss index 4de49f79ae..48320a6ea4 100644 --- a/app/javascript/flavours/glitch/components/account_header/styles.module.scss +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/hotkeys/index.tsx b/app/javascript/flavours/glitch/components/hotkeys/index.tsx index 99e8bc7966..8d511b68e3 100644 --- a/app/javascript/flavours/glitch/components/hotkeys/index.tsx +++ b/app/javascript/flavours/glitch/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, }); } @@ -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(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/flavours/glitch/components/hotkeys/utils.ts b/app/javascript/flavours/glitch/components/hotkeys/utils.ts index 1430e1685b..a5f0ddd64d 100644 --- a/app/javascript/flavours/glitch/components/hotkeys/utils.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/navigation_focus_target/index.tsx b/app/javascript/flavours/glitch/components/navigation_focus_target/index.tsx index f4b42926a8..1b86102fdf 100644 --- a/app/javascript/flavours/glitch/components/navigation_focus_target/index.tsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/entrypoints/wrapstodon.tsx b/app/javascript/flavours/glitch/entrypoints/wrapstodon.tsx index 4efcf619ad..7bb1ae9128 100644 --- a/app/javascript/flavours/glitch/entrypoints/wrapstodon.tsx +++ b/app/javascript/flavours/glitch/entrypoints/wrapstodon.tsx @@ -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() { - + + + , diff --git a/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx b/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx index 702122f0fd..ecc043cef5 100644 --- a/app/javascript/flavours/glitch/features/account_edit/modals/fields_modals.tsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/account_edit/styles.module.scss b/app/javascript/flavours/glitch/features/account_edit/styles.module.scss index 4991f8e7e5..0c8ba3b33a 100644 --- a/app/javascript/flavours/glitch/features/account_edit/styles.module.scss +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx index 9beaabaaac..6d685ba32f 100644 --- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/emoji/emoji_picker.tsx b/app/javascript/flavours/glitch/features/emoji/emoji_picker.tsx index 39fc548fd6..5a8ddb7ff1 100644 --- a/app/javascript/flavours/glitch/features/emoji/emoji_picker.tsx +++ b/app/javascript/flavours/glitch/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('@/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'); +} diff --git a/app/javascript/flavours/glitch/features/emoji/mode.ts b/app/javascript/flavours/glitch/features/emoji/mode.ts index 9cd45621cc..769e2859b8 100644 --- a/app/javascript/flavours/glitch/features/emoji/mode.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/emoji/picker.ts b/app/javascript/flavours/glitch/features/emoji/picker.ts index 9e131f7036..c8abfd9f96 100644 --- a/app/javascript/flavours/glitch/features/emoji/picker.ts +++ b/app/javascript/flavours/glitch/features/emoji/picker.ts @@ -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({ 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(); + 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/flavours/glitch/features/emoji/render.ts b/app/javascript/flavours/glitch/features/emoji/render.ts index affa8e0f16..e5ac5bcc6e 100644 --- a/app/javascript/flavours/glitch/features/emoji/render.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/emoji/types.ts b/app/javascript/flavours/glitch/features/emoji/types.ts index ea27a8f699..a5c408903e 100644 --- a/app/javascript/flavours/glitch/features/emoji/types.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/emoji/worker.ts b/app/javascript/flavours/glitch/features/emoji/worker.ts index d4f002daf6..49b7ce45f1 100644 --- a/app/javascript/flavours/glitch/features/emoji/worker.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/keyboard_shortcuts/index.jsx b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx index 8f75671322..938ebdc6b5 100644 --- a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx +++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx @@ -92,11 +92,11 @@ class KeyboardShortcuts extends ImmutablePureComponent { - k, + k, + - j, + j, + diff --git a/app/javascript/flavours/glitch/features/standalone/status/index.tsx b/app/javascript/flavours/glitch/features/standalone/status/index.tsx index aeddc3ef54..ba0f6de239 100644 --- a/app/javascript/flavours/glitch/features/standalone/status/index.tsx +++ b/app/javascript/flavours/glitch/features/standalone/status/index.tsx @@ -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 }) => { - + + + diff --git a/app/javascript/flavours/glitch/hooks/useAudioContext.ts b/app/javascript/flavours/glitch/hooks/useAudioContext.ts index 84acf5ac7f..2cad7ee3a5 100644 --- a/app/javascript/flavours/glitch/hooks/useAudioContext.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/hooks/useCustomEmojis.ts b/app/javascript/flavours/glitch/hooks/useCustomEmojis.ts index 6eb3c17fbf..26b4ebefab 100644 --- a/app/javascript/flavours/glitch/hooks/useCustomEmojis.ts +++ b/app/javascript/flavours/glitch/hooks/useCustomEmojis.ts @@ -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); } diff --git a/app/javascript/flavours/glitch/reducers/slices/emojis.ts b/app/javascript/flavours/glitch/reducers/slices/emojis.ts new file mode 100644 index 0000000000..df19f23206 --- /dev/null +++ b/app/javascript/flavours/glitch/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 '@/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>; + 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('@/flavours/glitch/features/emoji/database'); + return loadAllCustomEmoji(); + }, +); diff --git a/app/javascript/flavours/glitch/reducers/slices/index.ts b/app/javascript/flavours/glitch/reducers/slices/index.ts index 8d4ecd552d..1b94687cc6 100644 --- a/app/javascript/flavours/glitch/reducers/slices/index.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/styles/mastodon/components.scss b/app/javascript/flavours/glitch/styles/mastodon/components.scss index 98a930b993..41570d42c3 100644 --- a/app/javascript/flavours/glitch/styles/mastodon/components.scss +++ b/app/javascript/flavours/glitch/styles/mastodon/components.scss @@ -3083,7 +3083,6 @@ a.account__display-name { &__pane { height: 100%; - overflow: hidden; display: flex; justify-content: flex-end; min-width: 285px; 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,