From 24e42daa5142b5d494500c17e6349006f2036e00 Mon Sep 17 00:00:00 2001
From: Matiss Janis Aboltins <matiss@mja.lv>
Date: Sun, 24 Mar 2024 07:08:07 +0000
Subject: [PATCH] :bug: fix hotkeys sometimes not working (#2489)

---
 .eslintrc.js                                  |  1 -
 packages/desktop-client/package.json          |  2 +-
 .../desktop-client/src/components/App.tsx     | 41 ++++----
 .../src/components/FinancesApp.tsx            |  5 -
 .../src/components/KeyHandlers.tsx            | 87 ----------------
 .../src/components/Titlebar.tsx               | 99 ++++++++++---------
 .../src/components/accounts/Header.jsx        | 27 ++---
 .../src/components/common/Modal.tsx           | 17 ++--
 .../desktop-client/src/components/table.tsx   |  5 +-
 .../transactions/SelectedTransactions.jsx     | 45 ++++++---
 upcoming-release-notes/2489.md                |  6 ++
 yarn.lock                                     | 19 ++--
 12 files changed, 149 insertions(+), 205 deletions(-)
 delete mode 100644 packages/desktop-client/src/components/KeyHandlers.tsx
 create mode 100644 upcoming-release-notes/2489.md

diff --git a/.eslintrc.js b/.eslintrc.js
index a1374f06b..6762550f2 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -287,7 +287,6 @@ module.exports = {
         './packages/desktop-client/src/components/common/Menu.tsx',
         './packages/desktop-client/src/components/FinancesApp.tsx',
         './packages/desktop-client/src/components/GlobalKeys.ts',
-        './packages/desktop-client/src/components/KeyHandlers.tsx',
         './packages/desktop-client/src/components/LoggedInUser.tsx',
         './packages/desktop-client/src/components/manager/ManagementApp.jsx',
         './packages/desktop-client/src/components/manager/subscribe/common.tsx',
diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json
index 9e4d28668..a5a2f5592 100644
--- a/packages/desktop-client/package.json
+++ b/packages/desktop-client/package.json
@@ -37,7 +37,6 @@
     "downshift": "7.6.2",
     "focus-visible": "^4.1.5",
     "glamor": "^2.20.40",
-    "hotkeys-js": "^3.13.5",
     "inter-ui": "^3.19.3",
     "jest": "^27.5.1",
     "jest-watch-typeahead": "^2.2.2",
@@ -49,6 +48,7 @@
     "react-dnd-html5-backend": "^16.0.1",
     "react-dom": "18.2.0",
     "react-error-boundary": "^4.0.12",
+    "react-hotkeys-hook": "^4.5.0",
     "react-markdown": "^8.0.7",
     "react-merge-refs": "^1.1.0",
     "react-modal": "3.16.1",
diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx
index 8323f7247..0b4ea838e 100644
--- a/packages/desktop-client/src/components/App.tsx
+++ b/packages/desktop-client/src/components/App.tsx
@@ -5,6 +5,7 @@ import {
   useErrorBoundary,
   type FallbackProps,
 } from 'react-error-boundary';
+import { HotkeysProvider } from 'react-hotkeys-hook';
 import { useSelector } from 'react-redux';
 
 import * as Platform from 'loot-core/src/client/platform';
@@ -146,27 +147,29 @@ export function App() {
   }, [sync]);
 
   return (
-    <ResponsiveProvider>
-      <View
-        style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
-      >
+    <HotkeysProvider initiallyActiveScopes={['*']}>
+      <ResponsiveProvider>
         <View
-          key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
-          style={{
-            flexGrow: 1,
-            overflow: 'hidden',
-            ...styles.lightScrollbar,
-          }}
+          style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
         >
-          <ErrorBoundary FallbackComponent={ErrorFallback}>
-            {process.env.REACT_APP_REVIEW_ID && !Platform.isPlaywright && (
-              <DevelopmentTopBar />
-            )}
-            <AppInner budgetId={budgetId} cloudFileId={cloudFileId} />
-          </ErrorBoundary>
-          <ThemeStyle />
+          <View
+            key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
+            style={{
+              flexGrow: 1,
+              overflow: 'hidden',
+              ...styles.lightScrollbar,
+            }}
+          >
+            <ErrorBoundary FallbackComponent={ErrorFallback}>
+              {process.env.REACT_APP_REVIEW_ID && !Platform.isPlaywright && (
+                <DevelopmentTopBar />
+              )}
+              <AppInner budgetId={budgetId} cloudFileId={cloudFileId} />
+            </ErrorBoundary>
+            <ThemeStyle />
+          </View>
         </View>
-      </View>
-    </ResponsiveProvider>
+      </ResponsiveProvider>
+    </HotkeysProvider>
   );
 }
diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx
index 1dbf53dac..2a1bc50a0 100644
--- a/packages/desktop-client/src/components/FinancesApp.tsx
+++ b/packages/desktop-client/src/components/FinancesApp.tsx
@@ -12,8 +12,6 @@ import {
   useHref,
 } from 'react-router-dom';
 
-import hotkeys from 'hotkeys-js';
-
 import { SpreadsheetProvider } from 'loot-core/src/client/SpreadsheetProvider';
 import { type State } from 'loot-core/src/client/state-types';
 import { checkForUpdateNotification } from 'loot-core/src/client/update-notification';
@@ -99,9 +97,6 @@ function RouterBehaviors() {
 function FinancesAppWithoutContext() {
   const actions = useActions();
   useEffect(() => {
-    // The default key handler scope
-    hotkeys.setScope('app');
-
     // Wait a little bit to make sure the sync button will get the
     // sync start event. This can be improved later.
     setTimeout(async () => {
diff --git a/packages/desktop-client/src/components/KeyHandlers.tsx b/packages/desktop-client/src/components/KeyHandlers.tsx
deleted file mode 100644
index 13affa339..000000000
--- a/packages/desktop-client/src/components/KeyHandlers.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-// @ts-strict-ignore
-import React, { createContext, useEffect, useContext } from 'react';
-
-import hotkeys, { type KeyHandler as HotKeyHandler } from 'hotkeys-js';
-
-const KeyScopeContext = createContext('app');
-
-hotkeys.filter = event => {
-  const target = (event.target || event.srcElement) as HTMLElement;
-  const tagName = target.tagName;
-
-  // This is the default behavior of hotkeys, except we only suppress
-  // key presses if the meta key is not pressed
-  if (
-    !event.metaKey &&
-    (target.isContentEditable ||
-      ((tagName === 'INPUT' ||
-        tagName === 'TEXTAREA' ||
-        tagName === 'SELECT') &&
-        !target['readOnly']))
-  ) {
-    return false;
-  }
-
-  return true;
-};
-
-type KeyHandlerProps = {
-  keyName: string;
-  eventType?: string;
-  handler: HotKeyHandler;
-};
-function KeyHandler({
-  keyName,
-  eventType = 'keydown',
-  handler,
-}: KeyHandlerProps) {
-  const scope = useContext(KeyScopeContext);
-
-  if (eventType !== 'keyup' && eventType !== 'keydown') {
-    throw new Error('KeyHandler: unknown event type: ' + eventType);
-  }
-
-  useEffect(() => {
-    function _handler(event, hk) {
-      // Right now it always overrides the default behavior, but in
-      // the future we can make this customizable
-      event.preventDefault();
-
-      if (event.type === eventType && handler) {
-        return handler(event, hk);
-      }
-    }
-    hotkeys(keyName, { scope, keyup: true }, _handler);
-
-    return () => {
-      // @ts-expect-error unbind args typedef does not expect an object
-      hotkeys.unbind({
-        key: keyName,
-        scope,
-        method: _handler,
-      });
-    };
-  }, [keyName, handler, scope]);
-
-  return null;
-}
-
-type KeyHandlersProps = {
-  eventType?: string;
-  keys: Record<string, HotKeyHandler>;
-};
-export function KeyHandlers({ eventType, keys = {} }: KeyHandlersProps) {
-  const handlers = Object.keys(keys).map(key => {
-    return (
-      <KeyHandler
-        key={key}
-        keyName={key}
-        eventType={eventType}
-        handler={keys[key]}
-      />
-    );
-  });
-
-  // eslint-disable-next-line react/jsx-no-useless-fragment
-  return <>{handlers}</>;
-}
diff --git a/packages/desktop-client/src/components/Titlebar.tsx b/packages/desktop-client/src/components/Titlebar.tsx
index 8a08b283f..ad7e20ef6 100644
--- a/packages/desktop-client/src/components/Titlebar.tsx
+++ b/packages/desktop-client/src/components/Titlebar.tsx
@@ -6,6 +6,7 @@ import React, {
   useContext,
   type ReactNode,
 } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
 import { Routes, Route, useLocation } from 'react-router-dom';
 
 import * as Platform from 'loot-core/src/client/platform';
@@ -37,7 +38,6 @@ import { Link } from './common/Link';
 import { Paragraph } from './common/Paragraph';
 import { Text } from './common/Text';
 import { View } from './common/View';
-import { KeyHandlers } from './KeyHandlers';
 import { LoggedInUser } from './LoggedInUser';
 import { useServerURL } from './ServerContext';
 import { useSidebar } from './sidebar/SidebarProvider';
@@ -231,56 +231,57 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
     marginRight: 5,
   };
 
-  return (
-    <>
-      <KeyHandlers
-        keys={{
-          'ctrl+s, cmd+s': () => {
-            sync();
-          },
-        }}
-      />
+  useHotkeys(
+    'ctrl+s, cmd+s, meta+s',
+    sync,
+    {
+      enableOnFormTags: true,
+      preventDefault: true,
+      scopes: ['app'],
+    },
+    [sync],
+  );
 
-      <Button
-        type="bare"
-        aria-label="Sync"
-        style={
-          isMobile
-            ? {
-                ...style,
-                WebkitAppRegion: 'none',
-                ...mobileIconStyle,
-              }
-            : {
-                ...style,
-                WebkitAppRegion: 'none',
-                color: desktopColor,
-              }
-        }
-        hoveredStyle={hoveredStyle}
-        activeStyle={activeStyle}
-        onClick={sync}
-      >
-        {isMobile ? (
-          syncState === 'error' ? (
-            <SvgAlertTriangle width={14} height={14} />
-          ) : (
-            <AnimatedRefresh width={18} height={18} animating={syncing} />
-          )
-        ) : syncState === 'error' ? (
-          <SvgAlertTriangle width={13} />
+  return (
+    <Button
+      type="bare"
+      aria-label="Sync"
+      style={
+        isMobile
+          ? {
+              ...style,
+              WebkitAppRegion: 'none',
+              ...mobileIconStyle,
+            }
+          : {
+              ...style,
+              WebkitAppRegion: 'none',
+              color: desktopColor,
+            }
+      }
+      hoveredStyle={hoveredStyle}
+      activeStyle={activeStyle}
+      onClick={sync}
+    >
+      {isMobile ? (
+        syncState === 'error' ? (
+          <SvgAlertTriangle width={14} height={14} />
         ) : (
-          <AnimatedRefresh animating={syncing} />
-        )}
-        <Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
-          {syncState === 'disabled'
-            ? 'Disabled'
-            : syncState === 'offline'
-              ? 'Offline'
-              : 'Sync'}
-        </Text>
-      </Button>
-    </>
+          <AnimatedRefresh width={18} height={18} animating={syncing} />
+        )
+      ) : syncState === 'error' ? (
+        <SvgAlertTriangle width={13} />
+      ) : (
+        <AnimatedRefresh animating={syncing} />
+      )}
+      <Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
+        {syncState === 'disabled'
+          ? 'Disabled'
+          : syncState === 'offline'
+            ? 'Offline'
+            : 'Sync'}
+      </Text>
+    </Button>
   );
 }
 
diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx
index 078203fa6..1dea67b9f 100644
--- a/packages/desktop-client/src/components/accounts/Header.jsx
+++ b/packages/desktop-client/src/components/accounts/Header.jsx
@@ -1,4 +1,5 @@
 import React, { useState, useRef } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
 
 import { useLocalPref } from '../../hooks/useLocalPref';
 import { useSplitsExpanded } from '../../hooks/useSplitsExpanded';
@@ -24,7 +25,6 @@ import { Stack } from '../common/Stack';
 import { View } from '../common/View';
 import { FilterButton } from '../filters/FiltersMenu';
 import { FiltersStack } from '../filters/FiltersStack';
-import { KeyHandlers } from '../KeyHandlers';
 import { NotesButton } from '../NotesButton';
 import { SelectedTransactionsButton } from '../transactions/SelectedTransactions';
 
@@ -109,18 +109,23 @@ export function AccountHeader({
     }
   }
 
+  useHotkeys(
+    'ctrl+f, cmd+f, meta+f',
+    () => {
+      if (searchInput.current) {
+        searchInput.current.focus();
+      }
+    },
+    {
+      enableOnFormTags: true,
+      preventDefault: true,
+      scopes: ['app'],
+    },
+    [searchInput],
+  );
+
   return (
     <>
-      <KeyHandlers
-        keys={{
-          'ctrl+f, cmd+f': () => {
-            if (searchInput.current) {
-              searchInput.current.focus();
-            }
-          },
-        }}
-      />
-
       <View style={{ ...styles.pageContent, paddingBottom: 10, flexShrink: 0 }}>
         <View
           style={{ marginTop: 2, marginBottom: 10, alignItems: 'flex-start' }}
diff --git a/packages/desktop-client/src/components/common/Modal.tsx b/packages/desktop-client/src/components/common/Modal.tsx
index 13a7e3208..9c07af48f 100644
--- a/packages/desktop-client/src/components/common/Modal.tsx
+++ b/packages/desktop-client/src/components/common/Modal.tsx
@@ -6,10 +6,9 @@ import React, {
   type ReactNode,
   useState,
 } from 'react';
+import { useHotkeysContext } from 'react-hotkeys-hook';
 import ReactModal from 'react-modal';
 
-import hotkeys from 'hotkeys-js';
-
 import { AnimatedLoading } from '../../icons/AnimatedLoading';
 import { SvgDelete } from '../../icons/v0';
 import { type CSSProperties, styles, theme } from '../../style';
@@ -75,14 +74,14 @@ export const Modal = ({
   onClose,
   onTitleUpdate,
 }: ModalProps) => {
+  const { enableScope, disableScope } = useHotkeysContext();
+
+  // This deactivates any key handlers in the "app" scope
+  const scopeId = `modal-${stackIndex}-${title}`;
   useEffect(() => {
-    // This deactivates any key handlers in the "app" scope. Ideally
-    // each modal would have a name so they could each have their own
-    // key handlers, but we'll do that later
-    const prevScope = hotkeys.getScope();
-    hotkeys.setScope('modal');
-    return () => hotkeys.setScope(prevScope);
-  }, []);
+    enableScope(scopeId);
+    return () => disableScope(scopeId);
+  }, [enableScope, disableScope, scopeId]);
 
   const [isEditingTitle, setIsEditingTitle] = useState(false);
   const [_title, setTitle] = useState(title);
diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx
index 1259d0865..59ff2f195 100644
--- a/packages/desktop-client/src/components/table.tsx
+++ b/packages/desktop-client/src/components/table.tsx
@@ -34,7 +34,6 @@ import { Menu } from './common/Menu';
 import { Text } from './common/Text';
 import { View } from './common/View';
 import { FixedSizeList } from './FixedSizeList';
-import { KeyHandlers } from './KeyHandlers';
 import {
   ConditionalPrivacyFilter,
   mergeConditionalPrivacyFilterProps,
@@ -785,7 +784,7 @@ export function TableHeader({
   );
 }
 
-export function SelectedItemsButton({ name, keyHandlers, items, onSelect }) {
+export function SelectedItemsButton({ name, items, onSelect }) {
   const selectedItems = useSelectedItems();
   const [menuOpen, setMenuOpen] = useState(null);
 
@@ -795,8 +794,6 @@ export function SelectedItemsButton({ name, keyHandlers, items, onSelect }) {
 
   return (
     <View style={{ marginLeft: 10, flexShrink: 0 }}>
-      <KeyHandlers keys={keyHandlers || {}} />
-
       <Button
         type="bare"
         style={{ color: theme.pageTextPositive }}
diff --git a/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx b/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx
index dad536a1d..d08e73c70 100644
--- a/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx
+++ b/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx
@@ -1,4 +1,5 @@
 import React, { useMemo } from 'react';
+import { useHotkeys } from 'react-hotkeys-hook';
 
 import { isPreviewId } from 'loot-core/shared/transactions';
 import { validForTransfer } from 'loot-core/src/client/transfer';
@@ -63,20 +64,42 @@ export function SelectedTransactionsButton({
     return validForTransfer(fromTrans, toTrans);
   }, [selectedItems, getTransaction]);
 
+  const hotKeyOptions = {
+    enabled: types.trans,
+    scopes: ['app'],
+  };
+  useHotkeys('f', () => onShow([...selectedItems]), hotKeyOptions, [
+    onShow,
+    selectedItems,
+  ]);
+  useHotkeys('d', () => onDelete([...selectedItems]), hotKeyOptions, [
+    onDelete,
+    selectedItems,
+  ]);
+  useHotkeys('a', () => onEdit('account', [...selectedItems]), hotKeyOptions, [
+    onEdit,
+    selectedItems,
+  ]);
+  useHotkeys('p', () => onEdit('payee', [...selectedItems]), hotKeyOptions, [
+    onEdit,
+    selectedItems,
+  ]);
+  useHotkeys('n', () => onEdit('notes', [...selectedItems]), hotKeyOptions, [
+    onEdit,
+    selectedItems,
+  ]);
+  useHotkeys('c', () => onEdit('category', [...selectedItems]), hotKeyOptions, [
+    onEdit,
+    selectedItems,
+  ]);
+  useHotkeys('l', () => onEdit('cleared', [...selectedItems]), hotKeyOptions, [
+    onEdit,
+    selectedItems,
+  ]);
+
   return (
     <SelectedItemsButton
       name="transactions"
-      keyHandlers={
-        types.trans && {
-          f: () => onShow([...selectedItems]),
-          d: () => onDelete([...selectedItems]),
-          a: () => onEdit('account', [...selectedItems]),
-          p: () => onEdit('payee', [...selectedItems]),
-          n: () => onEdit('notes', [...selectedItems]),
-          c: () => onEdit('category', [...selectedItems]),
-          l: () => onEdit('cleared', [...selectedItems]),
-        }
-      }
       items={[
         ...(!types.trans
           ? [
diff --git a/upcoming-release-notes/2489.md b/upcoming-release-notes/2489.md
new file mode 100644
index 000000000..1d0bcba0d
--- /dev/null
+++ b/upcoming-release-notes/2489.md
@@ -0,0 +1,6 @@
+---
+category: Bugfix
+authors: [MatissJanis]
+---
+
+Fix hotkeys sometimes stopping to work.
diff --git a/yarn.lock b/yarn.lock
index 53cfcdcb7..c000e24ac 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -89,7 +89,6 @@ __metadata:
     downshift: "npm:7.6.2"
     focus-visible: "npm:^4.1.5"
     glamor: "npm:^2.20.40"
-    hotkeys-js: "npm:^3.13.5"
     inter-ui: "npm:^3.19.3"
     jest: "npm:^27.5.1"
     jest-watch-typeahead: "npm:^2.2.2"
@@ -101,6 +100,7 @@ __metadata:
     react-dnd-html5-backend: "npm:^16.0.1"
     react-dom: "npm:18.2.0"
     react-error-boundary: "npm:^4.0.12"
+    react-hotkeys-hook: "npm:^4.5.0"
     react-markdown: "npm:^8.0.7"
     react-merge-refs: "npm:^1.1.0"
     react-modal: "npm:3.16.1"
@@ -10534,13 +10534,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"hotkeys-js@npm:^3.13.5":
-  version: 3.13.5
-  resolution: "hotkeys-js@npm:3.13.5"
-  checksum: 739ac5924cfdd67571bfba85c0e6c0bc8ea1d617247b0e7126b1c4a3d04d14dbd35f5ee8455ac83f8703195b00c31eb8d8c06489143338fff9ac4129d16c007f
-  languageName: node
-  linkType: hard
-
 "html-encoding-sniffer@npm:^2.0.1":
   version: 2.0.1
   resolution: "html-encoding-sniffer@npm:2.0.1"
@@ -15070,6 +15063,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-hotkeys-hook@npm:^4.5.0":
+  version: 4.5.0
+  resolution: "react-hotkeys-hook@npm:4.5.0"
+  peerDependencies:
+    react: ">=16.8.1"
+    react-dom: ">=16.8.1"
+  checksum: f83d4ef7d56e40717f293d936101fb9d72f83b92568d5eb85594cf99e2b7c66556ff8f94e6dd9008813c7d097185593ff1b716b941ccf7ebf1969fecb4dc4b84
+  languageName: node
+  linkType: hard
+
 "react-is@npm:^16.10.2, react-is@npm:^16.13.1, react-is@npm:^16.7.0":
   version: 16.13.1
   resolution: "react-is@npm:16.13.1"
-- 
GitLab