diff --git a/app/javascript/flavours/glitch/components/hotkeys/index.tsx b/app/javascript/flavours/glitch/components/hotkeys/index.tsx new file mode 100644 index 0000000000..b5e0de4c59 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/hotkeys/utils.ts b/app/javascript/flavours/glitch/components/hotkeys/utils.ts new file mode 100644 index 0000000000..1430e1685b --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 4070ea2913..609c7694d9 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -7,8 +7,7 @@ import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { HotKeys } from 'react-hotkeys'; - +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { ContentWarning } from 'flavours/glitch/components/content_warning'; import { PictureInPicturePlaceholder } from 'flavours/glitch/components/picture_in_picture_placeholder'; import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning'; @@ -33,7 +32,6 @@ import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; import StatusIcons from './status_icons'; import StatusPrepend from './status_prepend'; - const domParser = new DOMParser(); export const textForScreenReader = (intl, status, rebloggedByText = false, expanded = false) => { @@ -499,13 +497,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')}}
-
+
); } @@ -516,7 +514,7 @@ class Status extends ImmutablePureComponent { }; return ( - +
: {matchedFilters.join(', ')}. {' '} @@ -524,7 +522,7 @@ class Status extends ImmutablePureComponent {
-
+
); } @@ -678,7 +676,7 @@ class Status extends ImmutablePureComponent { const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); return ( - +
-
+
); } diff --git a/app/javascript/flavours/glitch/components/status_list.jsx b/app/javascript/flavours/glitch/components/status_list.jsx index 95ff8f45e5..3323c6faae 100644 --- a/app/javascript/flavours/glitch/components/status_list.jsx +++ b/app/javascript/flavours/glitch/components/status_list.jsx @@ -57,7 +57,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); @@ -70,6 +70,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/flavours/glitch/features/compose/components/compose_form.jsx b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx index 96e252ad2f..bb366f6066 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx @@ -101,13 +101,17 @@ 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(e); } - if (e.keyCode === 13 && e.altKey) { + if (e.key.toLowerCase() === 'enter' && e.altKey) { this.handleSecondarySubmit(e); } + + if (['esc', 'escape'].includes(e.key.toLowerCase())) { + this.textareaRef.current?.blur(); + } }; getFulltextForCharacterCounting = () => { diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx index 48aec3f12f..85066ecf09 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/compose'; import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations'; import { openModal } from 'flavours/glitch/actions/modal'; import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'flavours/glitch/actions/statuses'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import AttachmentList from 'flavours/glitch/components/attachment_list'; import AvatarComposite from 'flavours/glitch/components/avatar_composite'; import { IconButton } from 'flavours/glitch/components/icon_button'; @@ -178,7 +176,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) }; return ( - +
@@ -228,7 +226,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
-
+
); }; diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.jsx b/app/javascript/flavours/glitch/features/notifications/components/notification.jsx index a2ed584135..cebb589691 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notification.jsx +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.jsx @@ -8,13 +8,13 @@ import { withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { HotKeys } from 'react-hotkeys'; import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react'; import PersonIcon from '@/material-icons/400-24px/person-fill.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react'; import { Account } from 'flavours/glitch/components/account'; import { Icon } from 'flavours/glitch/components/icon'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { Permalink } from 'flavours/glitch/components/permalink'; import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; @@ -121,7 +121,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -133,7 +133,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -141,7 +141,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -153,7 +153,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -313,7 +313,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
-
+
); } @@ -336,7 +336,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
-
+
); } @@ -352,7 +352,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -364,7 +364,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -391,7 +391,7 @@ class Notification extends ImmutablePureComponent { ); return ( - +
@@ -403,7 +403,7 @@ class Notification extends ImmutablePureComponent {
- + ); } diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx index 9bd6c27a86..f4a9013a70 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx @@ -1,9 +1,8 @@ import { useMemo } from 'react'; -import { HotKeys } from 'react-hotkeys'; - import { navigateToProfile } from 'flavours/glitch/actions/accounts'; import { mentionComposeById } from 'flavours/glitch/actions/compose'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import type { NotificationGroup as NotificationGroupModel } from 'flavours/glitch/models/notification_group'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; @@ -156,5 +155,5 @@ export const NotificationGroup: React.FC<{ return null; } - return {content}; + return {content}; }; diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group_with_status.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group_with_status.tsx index db12019f8e..774de6e54d 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group_with_status.tsx +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/compose'; import { navigateToStatus } from 'flavours/glitch/actions/statuses'; import { Avatar } from 'flavours/glitch/components/avatar'; import { AvatarGroup } from 'flavours/glitch/components/avatar_group'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import type { IconProp } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon'; import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; @@ -91,7 +90,7 @@ export const NotificationGroupWithStatus: React.FC<{ ); return ( - +
-
+
); }; diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_with_status.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_with_status.tsx index 9f0ead6888..371b141f55 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_with_status.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_with_status.tsx @@ -2,8 +2,6 @@ import { useMemo } from 'react'; import classNames from 'classnames'; -import { HotKeys } from 'react-hotkeys'; - import { replyComposeById } from 'flavours/glitch/actions/compose'; import { toggleReblog, @@ -13,6 +11,7 @@ import { navigateToStatus, toggleStatusSpoilers, } from 'flavours/glitch/actions/statuses'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import type { IconProp } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon'; import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted'; @@ -87,7 +86,7 @@ export const NotificationWithStatus: React.FC<{ if (!statusId || isFiltered) return null; return ( - +
-
+
); }; diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index 37cf4c9be5..da04de0cc7 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -10,11 +10,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; -import { HotKeys } from 'react-hotkeys'; - import ChatIcon from '@/material-icons/400-24px/chat.svg?react'; import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { Icon } from 'flavours/glitch/components/icon'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { TimelineHint } from 'flavours/glitch/components/timeline_hint'; @@ -647,7 +646,7 @@ class Status extends ImmutablePureComponent {
{ancestors} - +
-
+
{descendants} {remoteHint} diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index 0069fb603f..617d2a6d8e 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -10,13 +10,13 @@ import { connect } from 'react-redux'; import Favico from 'favico.js'; import { debounce } from 'lodash'; -import { HotKeys } from 'react-hotkeys'; import { focusApp, unfocusApp, changeLayout } from 'flavours/glitch/actions/app'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; import { fetchNotifications } from 'flavours/glitch/actions/notification_groups'; import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding'; import { AlertsController } from 'flavours/glitch/components/alerts_controller'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { HoverCardController } from 'flavours/glitch/components/hover_card_controller'; import { Permalink } from 'flavours/glitch/components/permalink'; import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture'; @@ -106,41 +106,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', - bookmark: 'd', - toggleSensitive: 'h', - openMedia: 'e', - onTranslate: 't', -}; - class SwitchingColumnsArea extends PureComponent { static propTypes = { identity: identityContextPropShape, @@ -416,6 +381,10 @@ class UI extends PureComponent { } }; + handleDonate = () => { + location.href = 'https://joinmastodon.org/sponsors#donate' + } + componentDidMount () { const { signedIn } = this.props.identity; @@ -443,10 +412,6 @@ class UI extends PureComponent { setTimeout(() => this.props.dispatch(fetchServer()), 3000); } - this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); - }; - if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support this.visibilityHiddenProp = 'hidden'; this.visibilityChange = 'visibilitychange'; @@ -556,10 +521,6 @@ class UI extends PureComponent { } }; - setHotkeysRef = c => { - this.hotkeys = c; - }; - handleHotkeyToggleHelp = () => { if (this.props.location.pathname === '/keyboard-shortcuts') { this.props.history.goBack(); @@ -647,10 +608,11 @@ class UI extends PureComponent { goToBlocked: this.handleHotkeyGoToBlocked, goToMuted: this.handleHotkeyGoToMuted, goToRequests: this.handleHotkeyGoToRequests, + cheat: this.handleDonate, }; return ( - +
{moved && (
- + ); }