diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1850a45bbcd..07400a07a49 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -23,7 +23,6 @@ matchManagers: ['npm'], matchPackageNames: [ 'tesseract.js', // Requires code changes - 'react-hotkeys', // Requires code changes // react-router: Requires manual upgrade 'history', diff --git a/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx b/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx new file mode 100644 index 00000000000..b95c9410e18 --- /dev/null +++ b/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; + +import type { HandlerMap } from '.'; +import { Hotkeys } from '.'; + +const meta = { + title: 'Components/Hotkeys', + component: Hotkeys, + args: { + global: undefined, + focusable: undefined, + handlers: {}, + }, + tags: ['test'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => { + async function confirmHotkey(name: string, shouldFind = true) { + // 'status' is the role of the 'output' element + const output = await canvas.findByRole('status'); + if (shouldFind) { + await expect(output).toHaveTextContent(name); + } else { + await expect(output).not.toHaveTextContent(name); + } + } + + const button = await canvas.findByRole('button'); + await userEvent.click(button); + + await userEvent.keyboard('n'); + await confirmHotkey('new'); + + await userEvent.keyboard('/'); + await confirmHotkey('search'); + + await userEvent.keyboard('o'); + await confirmHotkey('open'); + + await userEvent.keyboard('{Alt>}N{/Alt}'); + await confirmHotkey('forceNew'); + + await userEvent.keyboard('gh'); + await confirmHotkey('goToHome'); + + await userEvent.keyboard('gn'); + await confirmHotkey('goToNotifications'); + + await userEvent.keyboard('gf'); + await confirmHotkey('goToFavourites'); + + /** + * Ensure that hotkeys are not triggered when certain + * interactive elements are focused: + */ + + await userEvent.keyboard('{enter}'); + await confirmHotkey('open', false); + + const input = await canvas.findByRole('textbox'); + await userEvent.click(input); + + await userEvent.keyboard('n'); + await confirmHotkey('new', false); + + await userEvent.keyboard('{backspace}'); + await confirmHotkey('None', false); + + /** + * Reset playground: + */ + + await userEvent.click(button); + await userEvent.keyboard('{backspace}'); +}; + +export const Default = { + render: function Render() { + const [matchedHotkey, setMatchedHotkey] = useState( + null, + ); + + const handlers = { + back: () => { + setMatchedHotkey(null); + }, + new: () => { + setMatchedHotkey('new'); + }, + forceNew: () => { + setMatchedHotkey('forceNew'); + }, + search: () => { + setMatchedHotkey('search'); + }, + open: () => { + setMatchedHotkey('open'); + }, + goToHome: () => { + setMatchedHotkey('goToHome'); + }, + goToNotifications: () => { + setMatchedHotkey('goToNotifications'); + }, + goToFavourites: () => { + setMatchedHotkey('goToFavourites'); + }, + }; + + return ( + +
+

+ Hotkey playground +

+

+ Last pressed hotkey: {matchedHotkey ?? 'None'} +

+

+ Click within the dashed border and press the "n + " or "/" key. Press " + Backspace" to clear the displayed hotkey. +

+

+ Try typing a sequence, like "g" shortly + followed by "h", "n", or + "f" +

+

+ Note that this playground doesn't support all hotkeys we use in + the app. +

+

+ When a is focused, " + Enter + " should not trigger "open", but "o + " should. +

+

+ When an input element is focused, hotkeys should not interfere with + regular typing: +

+ +
+
+ ); + }, + play: hotkeyTest, +}; diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx new file mode 100644 index 00000000000..b5e0de4c594 --- /dev/null +++ b/app/javascript/mastodon/components/hotkeys/index.tsx @@ -0,0 +1,282 @@ +import { useEffect, useRef } from 'react'; + +import { normalizeKey, isKeyboardEvent } from './utils'; + +/** + * In case of multiple hotkeys matching the pressed key(s), + * the hotkey with a higher priority is selected. All others + * are ignored. + */ +const hotkeyPriority = { + singleKey: 0, + combo: 1, + sequence: 2, +} as const; + +/** + * This type of function receives a keyboard event and an array of + * previously pressed keys (within the last second), and returns + * `isMatch` (whether the pressed keys match a hotkey) and `priority` + * (a weighting used to resolve conflicts when two hotkeys match the + * pressed keys) + */ +type KeyMatcher = ( + event: KeyboardEvent, + bufferedKeys?: string[], +) => { + /** + * Whether the event.key matches the hotkey + */ + isMatch: boolean; + /** + * If there are multiple matching hotkeys, the + * first one with the highest priority will be handled + */ + priority: (typeof hotkeyPriority)[keyof typeof hotkeyPriority]; +}; + +/** + * Matches a single key + */ +function just(keyName: string): KeyMatcher { + return (event) => ({ + isMatch: normalizeKey(event.key) === keyName, + priority: hotkeyPriority.singleKey, + }); +} + +/** + * Matches any single key out of those provided + */ +function any(...keys: string[]): KeyMatcher { + return (event) => ({ + isMatch: keys.some((keyName) => just(keyName)(event).isMatch), + priority: hotkeyPriority.singleKey, + }); +} + +/** + * Matches a single key combined with the option/alt modifier + */ +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()}`, + priority: hotkeyPriority.combo, + }); +} + +/** + * Matches when all provided keys are pressed in sequence. + */ +function sequence(...sequence: string[]): KeyMatcher { + return (event, bufferedKeys) => { + const lastKeyInSequence = sequence.at(-1); + const startOfSequence = sequence.slice(0, -1); + const relevantBufferedKeys = bufferedKeys?.slice(-startOfSequence.length); + + const bufferMatchesStartOfSequence = + !!relevantBufferedKeys && + startOfSequence.join('') === relevantBufferedKeys.join(''); + + return { + isMatch: + bufferMatchesStartOfSequence && + normalizeKey(event.key) === lastKeyInSequence, + priority: hotkeyPriority.sequence, + }; + }; +} + +/** + * This is a map of all global hotkeys we support. + * To trigger a hotkey, a handler with a matching name must be + * provided to the `useHotkeys` hook or `Hotkeys` component. + */ +const hotkeyMatcherMap = { + help: just('?'), + search: any('s', '/'), + back: just('backspace'), + new: just('n'), + forceNew: optionPlus('n'), + focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'), + reply: just('r'), + favourite: just('f'), + boost: just('b'), + mention: just('m'), + open: any('enter', 'o'), + openProfile: just('p'), + moveDown: any('down', 'j'), + moveUp: any('up', 'k'), + toggleHidden: just('x'), + toggleSensitive: just('h'), + toggleComposeSpoilers: optionPlus('x'), + openMedia: just('e'), + onTranslate: just('t'), + goToHome: sequence('g', 'h'), + goToNotifications: sequence('g', 'n'), + goToLocal: sequence('g', 'l'), + goToFederated: sequence('g', 't'), + goToDirect: sequence('g', 'd'), + goToStart: sequence('g', 's'), + goToFavourites: sequence('g', 'f'), + goToPinned: sequence('g', 'p'), + goToProfile: sequence('g', 'u'), + goToBlocked: sequence('g', 'b'), + goToMuted: sequence('g', 'm'), + goToRequests: sequence('g', 'r'), + cheat: sequence( + 'up', + 'up', + 'down', + 'down', + 'left', + 'right', + 'left', + 'right', + 'b', + 'a', + 'enter', + ), +} as const; + +type HotkeyName = keyof typeof hotkeyMatcherMap; + +export type HandlerMap = Partial< + Record void> +>; + +export function useHotkeys(handlers: HandlerMap) { + const ref = useRef(null); + const bufferedKeys = useRef([]); + const sequenceTimer = useRef | null>(null); + + /** + * Store the latest handlers object in a ref so we don't need to + * add it as a dependency to the main event listener effect + */ + const handlersRef = useRef(handlers); + useEffect(() => { + handlersRef.current = handlers; + }, [handlers]); + + useEffect(() => { + const element = ref.current ?? document; + + function listener(event: Event) { + // Ignore key presses from input, textarea, or select elements + const tagName = (event.target as HTMLElement).tagName.toLowerCase(); + const shouldHandleEvent = + isKeyboardEvent(event) && + !event.defaultPrevented && + !['input', 'textarea', 'select'].includes(tagName) && + !( + ['a', 'button'].includes(tagName) && + normalizeKey(event.key) === 'enter' + ); + + if (shouldHandleEvent) { + const matchCandidates: { + handler: (event: KeyboardEvent) => void; + priority: number; + }[] = []; + + (Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach( + (handlerName) => { + const handler = handlersRef.current[handlerName]; + + if (handler) { + const hotkeyMatcher = hotkeyMatcherMap[handlerName]; + + const { isMatch, priority } = hotkeyMatcher( + event, + bufferedKeys.current, + ); + + if (isMatch) { + matchCandidates.push({ handler, priority }); + } + } + }, + ); + + // Sort all matches by priority + matchCandidates.sort((a, b) => b.priority - a.priority); + + const bestMatchingHandler = matchCandidates.at(0)?.handler; + if (bestMatchingHandler) { + bestMatchingHandler(event); + event.stopPropagation(); + event.preventDefault(); + } + + // Add last keypress to buffer + bufferedKeys.current.push(normalizeKey(event.key)); + + // Reset the timeout + if (sequenceTimer.current) { + clearTimeout(sequenceTimer.current); + } + sequenceTimer.current = setTimeout(() => { + bufferedKeys.current = []; + }, 1000); + } + } + element.addEventListener('keydown', listener); + + return () => { + element.removeEventListener('keydown', listener); + if (sequenceTimer.current) { + clearTimeout(sequenceTimer.current); + } + }; + }, []); + + return ref; +} + +/** + * The Hotkeys component allows us to globally register keyboard combinations + * under a name and assign actions to them, either globally or scoped to a portion + * of the app. + * + * ### How to use + * + * To add a new hotkey, add its key combination to the `hotkeyMatcherMap` object + * and give it a name. + * + * Use the `` component or the `useHotkeys` hook in the part of of the app + * where you want to handle the action, and pass in a handlers object. + * + * ```tsx + * + * ``` + * + * Now this function will be called when the 'open' hotkey is pressed by the user. + */ +export const Hotkeys: React.FC<{ + /** + * An object containing functions to be run when a hotkey is pressed. + * The key must be the name of a registered hotkey, e.g. "help" or "search" + */ + handlers: HandlerMap; + /** + * When enabled, hotkeys will be matched against the document root + * rather than only inside of this component's DOM node. + */ + global?: boolean; + /** + * Allow the rendered `div` to be focused + */ + focusable?: boolean; + children: React.ReactNode; +}> = ({ handlers, global, focusable = true, children }) => { + const ref = useHotkeys(handlers); + + return ( +
+ {children} +
+ ); +}; diff --git a/app/javascript/mastodon/components/hotkeys/utils.ts b/app/javascript/mastodon/components/hotkeys/utils.ts new file mode 100644 index 00000000000..1430e1685b3 --- /dev/null +++ b/app/javascript/mastodon/components/hotkeys/utils.ts @@ -0,0 +1,29 @@ +export function isKeyboardEvent(event: Event): event is KeyboardEvent { + return 'key' in event; +} + +export function normalizeKey(key: string): string { + const lowerKey = key.toLowerCase(); + + switch (lowerKey) { + case ' ': + case 'spacebar': // for older browsers + return 'space'; + + case 'arrowup': + return 'up'; + case 'arrowdown': + return 'down'; + case 'arrowleft': + return 'left'; + case 'arrowright': + return 'right'; + + case 'esc': + case 'escape': + return 'escape'; + + default: + return lowerKey; + } +} diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 29fd4234dd5..171ae780ff9 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -8,10 +8,9 @@ import { Link } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { HotKeys } from 'react-hotkeys'; - import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { ContentWarning } from 'mastodon/components/content_warning'; import { FilterWarning } from 'mastodon/components/filter_warning'; import { Icon } from 'mastodon/components/icon'; @@ -35,7 +34,6 @@ import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; import { StatusThreadLabel } from './status_thread_label'; import { VisibilityIcon } from './visibility_icon'; - const domParser = new DOMParser(); export const textForScreenReader = (intl, status, rebloggedByText = false) => { @@ -325,11 +323,11 @@ class Status extends ImmutablePureComponent { }; handleHotkeyMoveUp = e => { - this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured')); + this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured')); }; handleHotkeyMoveDown = e => { - this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured')); + this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured')); }; handleHotkeyToggleHidden = () => { @@ -437,13 +435,13 @@ class Status extends ImmutablePureComponent { if (hidden) { return ( - +
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('spoiler_text').length > 0 && ({status.get('spoiler_text')})} {expanded && {status.get('content')}}
-
+
); } @@ -543,7 +541,7 @@ class Status extends ImmutablePureComponent { const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); return ( - +
{!skipPrepend && prepend} @@ -604,7 +602,7 @@ class Status extends ImmutablePureComponent { }
-
+
); } diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index 390659e9b6a..cca449b0ca8 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -56,7 +56,7 @@ export default class StatusList extends ImmutablePureComponent { const elementIndex = this.getCurrentStatusIndex(id, featured) - 1; this._selectChild(elementIndex, true); }; - + handleMoveDown = (id, featured) => { const elementIndex = this.getCurrentStatusIndex(id, featured) + 1; this._selectChild(elementIndex, false); @@ -69,6 +69,7 @@ export default class StatusList extends ImmutablePureComponent { _selectChild (index, align_top) { const container = this.node.node; + // TODO: This breaks at the inline-follow-suggestions container const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); if (element) { diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 6dd3dbd0545..b563a6b45d4 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -93,9 +93,12 @@ class ComposeForm extends ImmutablePureComponent { }; handleKeyDown = (e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) { this.handleSubmit(); } + if (['esc', 'escape'].includes(e.key.toLowerCase())) { + this.textareaRef.current?.blur(); + } }; getFulltextForCharacterCounting = () => { diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx index c27cd3727f1..ec3621f0c06 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx @@ -10,15 +10,13 @@ import { createSelector } from '@reduxjs/toolkit'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { useDispatch, useSelector } from 'react-redux'; - -import { HotKeys } from 'react-hotkeys'; - import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import { replyCompose } from 'mastodon/actions/compose'; import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations'; import { openModal } from 'mastodon/actions/modal'; import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import AttachmentList from 'mastodon/components/attachment_list'; import AvatarComposite from 'mastodon/components/avatar_composite'; import { IconButton } from 'mastodon/components/icon_button'; @@ -169,7 +167,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) }; return ( - +
@@ -219,7 +217,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
-
+
); }; diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx index 86431f62fd5..b38e5da1594 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.jsx +++ b/app/javascript/mastodon/features/notifications/components/notification.jsx @@ -8,7 +8,6 @@ import { Link, withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { HotKeys } from 'react-hotkeys'; import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react'; @@ -20,6 +19,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import { Account } from 'mastodon/components/account'; import { Icon } from 'mastodon/components/icon'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import { me } from 'mastodon/initial_state'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; @@ -137,7 +137,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -149,7 +149,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -157,7 +157,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -169,7 +169,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -195,7 +195,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -217,7 +217,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -225,7 +225,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -247,7 +247,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -259,7 +259,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
@@ -282,7 +282,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -294,7 +294,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
@@ -317,7 +317,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -331,7 +331,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
@@ -358,7 +358,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -371,7 +371,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
-
+
); } @@ -394,7 +394,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
-
+
); } @@ -410,7 +410,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -422,7 +422,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -438,7 +438,7 @@ class Notification extends ImmutablePureComponent { const targetLink = ; return ( - +
@@ -450,7 +450,7 @@ class Notification extends ImmutablePureComponent {
- + ); } diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx index d5eb851985c..f0f2139ad21 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx @@ -1,9 +1,8 @@ import { useMemo } from 'react'; -import { HotKeys } from 'react-hotkeys'; - import { navigateToProfile } from 'mastodon/actions/accounts'; import { mentionComposeById } from 'mastodon/actions/compose'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; @@ -156,5 +155,5 @@ export const NotificationGroup: React.FC<{ return null; } - return {content}; + return {content}; }; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx index e7ed8792f67..4be1eefcddc 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx @@ -3,12 +3,11 @@ import type { JSX } from 'react'; import classNames from 'classnames'; -import { HotKeys } from 'react-hotkeys'; - import { replyComposeById } from 'mastodon/actions/compose'; import { navigateToStatus } from 'mastodon/actions/statuses'; import { Avatar } from 'mastodon/components/avatar'; import { AvatarGroup } from 'mastodon/components/avatar_group'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import type { IconProp } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; @@ -91,7 +90,7 @@ export const NotificationGroupWithStatus: React.FC<{ ); return ( - +
-
+
); }; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx index de484322fb0..96a4a4d65df 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx @@ -2,14 +2,13 @@ import { useMemo } from 'react'; import classNames from 'classnames'; -import { HotKeys } from 'react-hotkeys'; - import { replyComposeById } from 'mastodon/actions/compose'; import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions'; import { navigateToStatus, toggleStatusSpoilers, } from 'mastodon/actions/statuses'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import type { IconProp } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon'; import { StatusQuoteManager } from 'mastodon/components/status_quoted'; @@ -83,7 +82,7 @@ export const NotificationWithStatus: React.FC<{ if (!statusId || isFiltered) return null; return ( - +
-
+
); }; diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 0f02e7b50ff..64cd0c4f825 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -10,10 +10,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; -import { HotKeys } from 'react-hotkeys'; - import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { Icon } from 'mastodon/components/icon'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { TimelineHint } from 'mastodon/components/timeline_hint'; @@ -616,7 +615,7 @@ class Status extends ImmutablePureComponent {
{ancestors} - +
-
+
{descendants} {remoteHint} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index c9834eb0a48..e8eef704efa 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -9,13 +9,13 @@ import { Redirect, Route, withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; -import { HotKeys } from 'react-hotkeys'; import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; import { fetchNotifications } from 'mastodon/actions/notification_groups'; import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; import { AlertsController } from 'mastodon/components/alerts_controller'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { HoverCardController } from 'mastodon/components/hover_card_controller'; import { PictureInPicture } from 'mastodon/features/picture_in_picture'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; @@ -98,40 +98,6 @@ const mapStateToProps = state => ({ username: state.getIn(['accounts', me, 'username']), }); -const keyMap = { - help: '?', - new: 'n', - search: ['s', '/'], - forceNew: 'option+n', - toggleComposeSpoilers: 'option+x', - focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], - reply: 'r', - favourite: 'f', - boost: 'b', - mention: 'm', - open: ['enter', 'o'], - openProfile: 'p', - moveDown: ['down', 'j'], - moveUp: ['up', 'k'], - back: 'backspace', - goToHome: 'g h', - goToNotifications: 'g n', - goToLocal: 'g l', - goToFederated: 'g t', - goToDirect: 'g d', - goToStart: 'g s', - goToFavourites: 'g f', - goToPinned: 'g p', - goToProfile: 'g u', - goToBlocked: 'g b', - goToMuted: 'g m', - goToRequests: 'g r', - toggleHidden: 'x', - toggleSensitive: 'h', - openMedia: 'e', - onTranslate: 't', -}; - class SwitchingColumnsArea extends PureComponent { static propTypes = { identity: identityContextPropShape, @@ -400,6 +366,10 @@ class UI extends PureComponent { } }; + handleDonate = () => { + location.href = 'https://joinmastodon.org/sponsors#donate' + } + componentDidMount () { const { signedIn } = this.props.identity; @@ -426,10 +396,6 @@ class UI extends PureComponent { setTimeout(() => this.props.dispatch(fetchServer()), 3000); } - - this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); - }; } componentWillUnmount () { @@ -509,10 +475,6 @@ class UI extends PureComponent { } }; - setHotkeysRef = c => { - this.hotkeys = c; - }; - handleHotkeyToggleHelp = () => { if (this.props.location.pathname === '/keyboard-shortcuts') { this.props.history.goBack(); @@ -593,10 +555,11 @@ class UI extends PureComponent { goToBlocked: this.handleHotkeyGoToBlocked, goToMuted: this.handleHotkeyGoToMuted, goToRequests: this.handleHotkeyGoToRequests, + cheat: this.handleDonate, }; return ( - +
{children} @@ -611,7 +574,7 @@ class UI extends PureComponent {
-
+
); } diff --git a/package.json b/package.json index 2ddb85b7631..f19a5564f86 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,6 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", - "react-hotkeys": "^1.1.4", "react-immutable-proptypes": "^2.2.0", "react-immutable-pure-component": "^2.2.2", "react-intl": "^7.1.10", diff --git a/yarn.lock b/yarn.lock index 3575fbedde0..3ede60941aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2706,7 +2706,6 @@ __metadata: react: "npm:^18.2.0" react-dom: "npm:^18.2.0" react-helmet: "npm:^6.1.0" - react-hotkeys: "npm:^1.1.4" react-immutable-proptypes: "npm:^2.2.0" react-immutable-pure-component: "npm:^2.2.2" react-intl: "npm:^7.1.10" @@ -9172,27 +9171,6 @@ __metadata: languageName: node linkType: hard -"lodash.isboolean@npm:^3.0.3": - version: 3.0.3 - resolution: "lodash.isboolean@npm:3.0.3" - checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7 - languageName: node - linkType: hard - -"lodash.isequal@npm:^4.5.0": - version: 4.5.0 - resolution: "lodash.isequal@npm:4.5.0" - checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f - languageName: node - linkType: hard - -"lodash.isobject@npm:^3.0.2": - version: 3.0.2 - resolution: "lodash.isobject@npm:3.0.2" - checksum: 10c0/da4c8480d98b16835b59380b2fbd43c54081acd9466febb788ba77c434384349e0bec162d1c4e89f613f21687b2b6d8384d8a112b80da00c78d28d9915a5cdde - languageName: node - linkType: hard - "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -9603,13 +9581,6 @@ __metadata: languageName: node linkType: hard -"mousetrap@npm:^1.5.2": - version: 1.6.5 - resolution: "mousetrap@npm:1.6.5" - checksum: 10c0/5c361bdbbff3966fd58d70f39b9fe1f8e32c78f3ce65989d83af7aad32a3a95313ce835a8dd8a55cb5de9eeb7c1f0c2b9048631a3073b5606241589e8fc0ba53 - languageName: node - linkType: hard - "mrmime@npm:^2.0.0": version: 2.0.1 resolution: "mrmime@npm:2.0.1" @@ -11115,22 +11086,6 @@ __metadata: languageName: node linkType: hard -"react-hotkeys@npm:^1.1.4": - version: 1.1.4 - resolution: "react-hotkeys@npm:1.1.4" - dependencies: - lodash.isboolean: "npm:^3.0.3" - lodash.isequal: "npm:^4.5.0" - lodash.isobject: "npm:^3.0.2" - mousetrap: "npm:^1.5.2" - prop-types: "npm:^15.6.0" - peerDependencies: - react: ">= 0.14.0" - react-dom: ">= 0.14.0" - checksum: 10c0/6bd566ea97e00058749d43d768ee843e5132f988571536e090b564d5dbaa71093695255514fc5b9fcf9fbd03fcb0603f6e135dcab6dcaaffe43dedbfe742a163 - languageName: node - linkType: hard - "react-immutable-proptypes@npm:^2.2.0": version: 2.2.0 resolution: "react-immutable-proptypes@npm:2.2.0"