From b0bdb78543552e39fd4c425b037c609ade751b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 2 Jul 2023 12:49:09 +0200 Subject: [PATCH] Fix hotkey navigation in media modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/status-list.tsx | 13 +- app/soapbox/components/ui/card/card.tsx | 1 + app/soapbox/containers/soapbox.tsx | 186 +++++++++++++-- .../feed-suggestions/feed-suggestions.tsx | 64 +++-- .../features/status/components/thread.tsx | 9 +- app/soapbox/features/ui/index.tsx | 222 ++++-------------- 6 files changed, 265 insertions(+), 230 deletions(-) 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..64b66656e 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -2,18 +2,21 @@ import { QueryClientProvider } from '@tanstack/react-query'; import clsx from 'clsx'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Toaster } from 'react-hot-toast'; +import { HotKeys } from 'react-hotkeys'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; -import { BrowserRouter, Switch, Redirect, Route } from 'react-router-dom'; +import { BrowserRouter, Switch, Redirect, Route, useHistory } from 'react-router-dom'; import { CompatRouter } from 'react-router-dom-v5-compat'; // @ts-ignore: it doesn't have types import { ScrollContext } from 'react-router-scroll-4'; +import { resetCompose } from 'soapbox/actions/compose'; import { loadInstance } from 'soapbox/actions/instance'; import { fetchMe } from 'soapbox/actions/me'; +import { openModal } from 'soapbox/actions/modals'; import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox'; import { fetchVerificationConfig } from 'soapbox/actions/verification'; import * as BuildConfig from 'soapbox/build-config'; @@ -64,6 +67,34 @@ store.dispatch(preload() as any); // This happens synchronously store.dispatch(checkOnboardingStatus() as any); +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', +}; + /** Load initial data from the backend */ const loadInitial = () => { // @ts-ignore @@ -89,6 +120,10 @@ const loadInitial = () => { const SoapboxMount = () => { useCachedLocationHandler(); + const hotkeys = useRef(null); + + const history = useHistory(); + const dispatch = useAppDispatch(); const me = useAppSelector(state => state.me); const instance = useInstance(); const { account } = useOwnAccount(); @@ -106,6 +141,109 @@ const SoapboxMount = () => { return !(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey); }; + 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, + }; + /** Render the onboarding flow. */ const renderOnboarding = () => ( @@ -176,31 +314,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 847b29d26..187c52c09 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'; @@ -154,34 +152,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 } @@ -396,7 +366,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(); @@ -527,91 +496,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/)); @@ -620,85 +504,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 => } +
- +
); };