diff --git a/app/soapbox/components/status-list.tsx b/app/soapbox/components/status-list.tsx index 439803e02..1d4165d22 100644 --- a/app/soapbox/components/status-list.tsx +++ b/app/soapbox/components/status-list.tsx @@ -178,8 +178,15 @@ const StatusList: React.FC = ({ )); }; - const renderFeedSuggestions = (): React.ReactNode => { - return ; + const renderFeedSuggestions = (statusId: string): React.ReactNode => { + return ( + + ); }; const renderStatuses = (): React.ReactNode[] => { @@ -201,7 +208,7 @@ const StatusList: React.FC = ({ } } else if (statusId.startsWith('末suggestions-')) { if (soapboxConfig.feedInjection) { - acc.push(renderFeedSuggestions()); + acc.push(renderFeedSuggestions(statusId)); } } else if (statusId.startsWith('末pending-')) { acc.push(renderPendingStatus(statusId)); diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index 4b8d9799d..a57fc9a1a 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -27,6 +27,7 @@ interface ICard { className?: string /** Elements inside the card. */ children: React.ReactNode + tabIndex?: number } /** An opaque backdrop to hold a collection of related elements. */ diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index aa935624d..16f977289 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -11,7 +11,6 @@ import { CompatRouter } from 'react-router-dom-v5-compat'; // @ts-ignore: it doesn't have types import { ScrollContext } from 'react-router-scroll-4'; - import { loadInstance } from 'soapbox/actions/instance'; import { fetchMe } from 'soapbox/actions/me'; import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox'; @@ -30,6 +29,7 @@ import { OnboardingWizard, WaitlistPage, } from 'soapbox/features/ui/util/async-components'; +import GlobalHotkeys from 'soapbox/features/ui/util/global-hotkeys'; import { createGlobals } from 'soapbox/globals'; import { useAppSelector, @@ -176,31 +176,33 @@ const SoapboxMount = () => { - - } - /> - + + + } + /> + - - {renderBody()} + + {renderBody()} - - {Component => } - + + {Component => } + - + -
- -
-
-
+
+ +
+
+
+
diff --git a/app/soapbox/features/feed-suggestions/feed-suggestions.tsx b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx index cac53d103..6db977449 100644 --- a/app/soapbox/features/feed-suggestions/feed-suggestions.tsx +++ b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { HotKeys } from 'react-hotkeys'; import { defineMessages, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; @@ -61,34 +62,59 @@ const SuggestionItem: React.FC = ({ accountId }) => { ); }; -const FeedSuggestions = () => { +interface IFeedSuggesetions { + statusId: string + onMoveUp?: (statusId: string, featured?: boolean) => void + onMoveDown?: (statusId: string, featured?: boolean) => void +} + +const FeedSuggestions: React.FC = ({ statusId, onMoveUp, onMoveDown }) => { const intl = useIntl(); const suggestedProfiles = useAppSelector((state) => state.suggestions.items); const isLoading = useAppSelector((state) => state.suggestions.isLoading); if (!isLoading && suggestedProfiles.size === 0) return null; + const handleHotkeyMoveUp = (e?: KeyboardEvent): void => { + if (onMoveUp) { + onMoveUp(statusId); + } + }; + + const handleHotkeyMoveDown = (e?: KeyboardEvent): void => { + if (onMoveDown) { + onMoveDown(statusId); + } + }; + + const handlers = { + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, + }; + return ( - - - + + + + - - {intl.formatMessage(messages.viewAll)} - - - - - - {suggestedProfiles.slice(0, 4).map((suggestedProfile) => ( - - ))} + + {intl.formatMessage(messages.viewAll)} + - - + + + + {suggestedProfiles.slice(0, 4).map((suggestedProfile) => ( + + ))} + + + + ); }; diff --git a/app/soapbox/features/status/components/thread.tsx b/app/soapbox/features/status/components/thread.tsx index 058f02a08..e50ee8615 100644 --- a/app/soapbox/features/status/components/thread.tsx +++ b/app/soapbox/features/status/components/thread.tsx @@ -263,15 +263,12 @@ const Thread = (props: IThread) => { }; const _selectChild = (index: number) => { + if (!useWindowScroll) index = index + 1; scroller.current?.scrollIntoView({ index, behavior: 'smooth', done: () => { - const element = document.querySelector(`#thread [data-index="${index}"] .focusable`); - - if (element) { - element.focus(); - } + node.current?.querySelector(`[data-index="${index}"] .focusable`)?.focus(); }, }); }; @@ -465,4 +462,4 @@ const Thread = (props: IThread) => { ); }; -export default Thread; \ No newline at end of file +export default Thread; diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index f0cc8c561..040f6c677 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -2,13 +2,11 @@ import clsx from 'clsx'; import React, { useEffect, useRef } from 'react'; -import { HotKeys } from 'react-hotkeys'; import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom'; import { fetchFollowRequests } from 'soapbox/actions/accounts'; import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin'; import { fetchAnnouncements } from 'soapbox/actions/announcements'; -import { resetCompose } from 'soapbox/actions/compose'; import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis'; import { fetchFilters } from 'soapbox/actions/filters'; import { fetchMarker } from 'soapbox/actions/markers'; @@ -155,34 +153,6 @@ const GroupMembershipRequestsSlug = withHoc(GroupMembershipRequests as any, Grou const EmptyPage = HomePage; -const keyMap = { - help: '?', - new: 'n', - search: ['s', '/'], - forceNew: 'option+n', - reply: 'r', - favourite: 'f', - react: 'e', - boost: 'b', - mention: 'm', - open: ['enter', 'o'], - openProfile: 'p', - moveDown: ['down', 'j'], - moveUp: ['up', 'k'], - back: 'backspace', - goToHome: 'g h', - goToNotifications: 'g n', - goToFavourites: 'g f', - goToPinned: 'g p', - goToProfile: 'g u', - goToBlocked: 'g b', - goToMuted: 'g m', - goToRequests: 'g r', - toggleHidden: 'x', - toggleSensitive: 'h', - openMedia: 'a', -}; - interface ISwitchingColumnsArea { children: React.ReactNode } @@ -398,7 +368,6 @@ const UI: React.FC = ({ children }) => { const userStream = useRef(null); const nostrStream = useRef(null); const node = useRef(null); - const hotkeys = useRef(null); const me = useAppSelector(state => state.me); const { account } = useOwnAccount(); @@ -529,91 +498,6 @@ const UI: React.FC = ({ children }) => { } }, [pendingPolicy, !!account]); - const handleHotkeyNew = (e?: KeyboardEvent) => { - e?.preventDefault(); - if (!node.current) return; - - const element = node.current.querySelector('textarea#compose-textarea') as HTMLTextAreaElement; - - if (element) { - element.focus(); - } - }; - - const handleHotkeySearch = (e?: KeyboardEvent) => { - e?.preventDefault(); - if (!node.current) return; - - const element = node.current.querySelector('input#search') as HTMLInputElement; - - if (element) { - element.focus(); - } - }; - - const handleHotkeyForceNew = (e?: KeyboardEvent) => { - handleHotkeyNew(e); - dispatch(resetCompose()); - }; - - const handleHotkeyBack = () => { - if (window.history && window.history.length === 1) { - history.push('/'); - } else { - history.goBack(); - } - }; - - const setHotkeysRef: React.LegacyRef = (c: any) => { - hotkeys.current = c; - - if (!me || !hotkeys.current) return; - - // @ts-ignore - hotkeys.current.__mousetrap__.stopCallback = (_e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT', 'EM-EMOJI-PICKER'].includes(element.tagName); - }; - }; - - const handleHotkeyToggleHelp = () => { - dispatch(openModal('HOTKEYS')); - }; - - const handleHotkeyGoToHome = () => { - history.push('/'); - }; - - const handleHotkeyGoToNotifications = () => { - history.push('/notifications'); - }; - - const handleHotkeyGoToFavourites = () => { - if (!account) return; - history.push(`/@${account.username}/favorites`); - }; - - const handleHotkeyGoToPinned = () => { - if (!account) return; - history.push(`/@${account.username}/pins`); - }; - - const handleHotkeyGoToProfile = () => { - if (!account) return; - history.push(`/@${account.username}`); - }; - - const handleHotkeyGoToBlocked = () => { - history.push('/blocks'); - }; - - const handleHotkeyGoToMuted = () => { - history.push('/mutes'); - }; - - const handleHotkeyGoToRequests = () => { - history.push('/follow_requests'); - }; - const shouldHideFAB = (): boolean => { const path = location.pathname; return Boolean(path.match(/^\/posts\/|^\/search|^\/getting-started|^\/chats/)); @@ -622,85 +506,65 @@ const UI: React.FC = ({ children }) => { // Wait for login to succeed or fail if (me === null) return null; - type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; - - const handlers: HotkeyHandlers = { - help: handleHotkeyToggleHelp, - new: handleHotkeyNew, - search: handleHotkeySearch, - forceNew: handleHotkeyForceNew, - back: handleHotkeyBack, - goToHome: handleHotkeyGoToHome, - goToNotifications: handleHotkeyGoToNotifications, - goToFavourites: handleHotkeyGoToFavourites, - goToPinned: handleHotkeyGoToPinned, - goToProfile: handleHotkeyGoToProfile, - goToBlocked: handleHotkeyGoToBlocked, - goToMuted: handleHotkeyGoToMuted, - goToRequests: handleHotkeyGoToRequests, - }; - const style: React.CSSProperties = { pointerEvents: dropdownMenuIsOpen ? 'none' : undefined, }; return ( - -
-
+
+
- + -
- +
+ - - - {!standalone && } - + + + {!standalone && } + - - {children} - - + + {children} + + - {(me && !shouldHideFAB()) && ( -
- -
- )} + {(me && !shouldHideFAB()) && ( +
+ +
+ )} - {me && ( - - {Component => } - - )} - - {me && features.chats && ( - - {Component => ( -
- -
- )} -
- )} - - - + {me && ( + {Component => } + )} - - {Component => } + {me && features.chats && ( + + {Component => ( +
+ +
+ )}
-
+ )} + + + + {Component => } + + + + {Component => } +
- +
); }; diff --git a/app/soapbox/features/ui/util/global-hotkeys.tsx b/app/soapbox/features/ui/util/global-hotkeys.tsx new file mode 100644 index 000000000..aeb4d42fb --- /dev/null +++ b/app/soapbox/features/ui/util/global-hotkeys.tsx @@ -0,0 +1,159 @@ +import React, { useRef } from 'react'; +import { HotKeys } from 'react-hotkeys'; +import { useHistory } from 'react-router-dom'; + +import { resetCompose } from 'soapbox/actions/compose'; +import { openModal } from 'soapbox/actions/modals'; +import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks'; + +const keyMap = { + help: '?', + new: 'n', + search: ['s', '/'], + forceNew: 'option+n', + reply: 'r', + favourite: 'f', + react: 'e', + boost: 'b', + mention: 'm', + open: ['enter', 'o'], + openProfile: 'p', + moveDown: ['down', 'j'], + moveUp: ['up', 'k'], + back: 'backspace', + goToHome: 'g h', + goToNotifications: 'g n', + goToFavourites: 'g f', + goToPinned: 'g p', + goToProfile: 'g u', + goToBlocked: 'g b', + goToMuted: 'g m', + goToRequests: 'g r', + toggleHidden: 'x', + toggleSensitive: 'h', + openMedia: 'a', +}; + +interface IGlobalHotkeys { + children: React.ReactNode +} + +const GlobalHotkeys: React.FC = ({ children }) => { + const hotkeys = useRef(null); + + const history = useHistory(); + const dispatch = useAppDispatch(); + const me = useAppSelector(state => state.me); + const { account } = useOwnAccount(); + + const handleHotkeyNew = (e?: KeyboardEvent) => { + e?.preventDefault(); + if (!hotkeys.current) return; + + const element = hotkeys.current.querySelector('textarea#compose-textarea') as HTMLTextAreaElement; + + if (element) { + element.focus(); + } + }; + + const handleHotkeySearch = (e?: KeyboardEvent) => { + e?.preventDefault(); + if (!hotkeys.current) return; + + const element = hotkeys.current.querySelector('input#search') as HTMLInputElement; + + if (element) { + element.focus(); + } + }; + + const handleHotkeyForceNew = (e?: KeyboardEvent) => { + handleHotkeyNew(e); + dispatch(resetCompose()); + }; + + const handleHotkeyBack = () => { + if (window.history && window.history.length === 1) { + history.push('/'); + } else { + history.goBack(); + } + }; + + const setHotkeysRef: React.LegacyRef = (c: any) => { + hotkeys.current = c; + + if (!me || !hotkeys.current) return; + + // @ts-ignore + hotkeys.current.__mousetrap__.stopCallback = (_e, element) => { + return ['TEXTAREA', 'SELECT', 'INPUT', 'EM-EMOJI-PICKER'].includes(element.tagName); + }; + }; + + const handleHotkeyToggleHelp = () => { + dispatch(openModal('HOTKEYS')); + }; + + const handleHotkeyGoToHome = () => { + history.push('/'); + }; + + const handleHotkeyGoToNotifications = () => { + history.push('/notifications'); + }; + + const handleHotkeyGoToFavourites = () => { + if (!account) return; + history.push(`/@${account.username}/favorites`); + }; + + const handleHotkeyGoToPinned = () => { + if (!account) return; + history.push(`/@${account.username}/pins`); + }; + + const handleHotkeyGoToProfile = () => { + if (!account) return; + history.push(`/@${account.username}`); + }; + + const handleHotkeyGoToBlocked = () => { + history.push('/blocks'); + }; + + const handleHotkeyGoToMuted = () => { + history.push('/mutes'); + }; + + const handleHotkeyGoToRequests = () => { + history.push('/follow_requests'); + }; + + type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; + + const handlers: HotkeyHandlers = { + help: handleHotkeyToggleHelp, + new: handleHotkeyNew, + search: handleHotkeySearch, + forceNew: handleHotkeyForceNew, + back: handleHotkeyBack, + goToHome: handleHotkeyGoToHome, + goToNotifications: handleHotkeyGoToNotifications, + goToFavourites: handleHotkeyGoToFavourites, + goToPinned: handleHotkeyGoToPinned, + goToProfile: handleHotkeyGoToProfile, + goToBlocked: handleHotkeyGoToBlocked, + goToMuted: handleHotkeyGoToMuted, + goToRequests: handleHotkeyGoToRequests, + }; + + return ( + + {children} + + ); +}; + +export default GlobalHotkeys;