diff --git a/packages/app/src/Hooks/useKeyboardShortcut.ts b/packages/app/src/Hooks/useKeyboardShortcut.ts new file mode 100644 index 00000000..2f30d88c --- /dev/null +++ b/packages/app/src/Hooks/useKeyboardShortcut.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; + +export default function useKeyboardShortcut(key: string, callback: (event: KeyboardEvent) => void, node = null) { + // implement the callback ref pattern + const callbackRef = useRef(callback); + useLayoutEffect(() => { + callbackRef.current = callback; + }); + + // handle what happens on key press + const handleKeyPress = useCallback( + (event: KeyboardEvent) => { + // check if one of the key is part of the ones we want + if (event.key === key) { + callbackRef.current(event); + } + }, + [key], + ); + + useEffect(() => { + // target is either the provided node or the document + const targetNode = node ?? document; + // attach the event listener + targetNode && targetNode.addEventListener("keydown", handleKeyPress); + + // remove the event listener + return () => targetNode && targetNode.removeEventListener("keydown", handleKeyPress); + }, [handleKeyPress, node]); +} diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index c543f200..c9d52f46 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -1,5 +1,5 @@ import "./Layout.css"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { useUserProfile } from "@snort/system-react"; @@ -13,7 +13,7 @@ import { NoteCreator } from "Element/Event/NoteCreator"; import { mapPlanName } from "./subscribe"; import useLogin from "Hooks/useLogin"; import Avatar from "Element/User/Avatar"; -import { profileLink } from "SnortUtils"; +import { isFormElement, profileLink } from "SnortUtils"; import { getCurrentSubscription } from "Subscription"; import Toaster from "Toaster"; import Spinner from "Icons/Spinner"; @@ -22,6 +22,7 @@ import { useTheme } from "Hooks/useTheme"; import { useLoginRelays } from "Hooks/useLoginRelays"; import { useNoteCreator } from "State/NoteCreator"; import { LoginUnlock } from "Element/PinPrompt"; +import useKeyboardShortcut from "Hooks/useKeyboardShortcut"; import { LoginStore } from "Login"; export default function Layout() { @@ -32,6 +33,16 @@ export default function Layout() { useLoginFeed(); useTheme(); useLoginRelays(); + useKeyboardShortcut(".", event => { + // if event happened in a form element, do nothing, otherwise focus on search input + if (event.target && !isFormElement(event.target as HTMLElement)) { + event.preventDefault(); + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + }); const shouldHideHeader = useMemo(() => { const hideOn = ["/login", "/new"]; @@ -78,10 +89,21 @@ export default function Layout() { } const NoteCreatorButton = () => { + const buttonRef = useRef(null); const location = useLocation(); const { readonly } = useLogin(s => ({ readonly: s.readonly })); const { show, replyTo, update } = useNoteCreator(v => ({ show: v.show, replyTo: v.replyTo, update: v.update })); + useKeyboardShortcut("n", event => { + // if event happened in a form element, do nothing, otherwise focus on search input + if (event.target && !isFormElement(event.target as HTMLElement)) { + event.preventDefault(); + if (buttonRef.current) { + buttonRef.current.click(); + } + } + }); + const shouldHideNoteCreator = useMemo(() => { const isReplyNoteCreatorShowing = replyTo && show; const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"]; @@ -92,6 +114,7 @@ const NoteCreatorButton = () => { return ( <>