Merge branch 'hotkeys-statuses' into 'develop'

Fix hotkey navigation in media modal

See merge request soapbox-pub/soapbox!2589
This commit is contained in:
marcin mikołajczak 2023-07-02 21:27:10 +00:00
commit 42e9d31a3e
7 changed files with 285 additions and 229 deletions

View File

@ -178,8 +178,15 @@ const StatusList: React.FC<IStatusList> = ({
));
};
const renderFeedSuggestions = (): React.ReactNode => {
return <FeedSuggestions key='suggestions' />;
const renderFeedSuggestions = (statusId: string): React.ReactNode => {
return (
<FeedSuggestions
key='suggestions'
statusId={statusId}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
);
};
const renderStatuses = (): React.ReactNode[] => {
@ -201,7 +208,7 @@ const StatusList: React.FC<IStatusList> = ({
}
} else if (statusId.startsWith('末suggestions-')) {
if (soapboxConfig.feedInjection) {
acc.push(renderFeedSuggestions());
acc.push(renderFeedSuggestions(statusId));
}
} else if (statusId.startsWith('末pending-')) {
acc.push(renderPendingStatus(statusId));

View File

@ -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. */

View File

@ -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,6 +176,7 @@ const SoapboxMount = () => {
<BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}>
<CompatRouter>
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
<GlobalHotkeys>
<Switch>
<Route
path='/embed/:statusId'
@ -201,6 +202,7 @@ const SoapboxMount = () => {
</div>
</Route>
</Switch>
</GlobalHotkeys>
</ScrollContext>
</CompatRouter>
</BrowserRouter>

View File

@ -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,15 +62,39 @@ const SuggestionItem: React.FC<ISuggestionItem> = ({ accountId }) => {
);
};
const FeedSuggestions = () => {
interface IFeedSuggesetions {
statusId: string
onMoveUp?: (statusId: string, featured?: boolean) => void
onMoveDown?: (statusId: string, featured?: boolean) => void
}
const FeedSuggestions: React.FC<IFeedSuggesetions> = ({ 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 (
<Card size='lg' variant='rounded' className='space-y-6'>
<HotKeys handlers={handlers}>
<Card size='lg' variant='rounded' className='focusable space-y-6' tabIndex={0}>
<HStack justifyContent='between' alignItems='center'>
<CardTitle title={intl.formatMessage(messages.heading)} />
@ -89,6 +114,7 @@ const FeedSuggestions = () => {
</HStack>
</CardBody>
</Card>
</HotKeys>
);
};

View File

@ -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<HTMLDivElement>(`#thread [data-index="${index}"] .focusable`);
if (element) {
element.focus();
}
node.current?.querySelector<HTMLDivElement>(`[data-index="${index}"] .focusable`)?.focus();
},
});
};

View File

@ -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<IUI> = ({ children }) => {
const userStream = useRef<any>(null);
const nostrStream = useRef<any>(null);
const node = useRef<HTMLDivElement | null>(null);
const hotkeys = useRef<HTMLDivElement | null>(null);
const me = useAppSelector(state => state.me);
const { account } = useOwnAccount();
@ -529,91 +498,6 @@ const UI: React.FC<IUI> = ({ 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<HotKeys> = (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,30 +506,11 @@ const UI: React.FC<IUI> = ({ 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 (
<HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused>
<div ref={node} style={style}>
<div
className={clsx('pointer-events-none fixed z-[90] h-screen w-screen transition', {
@ -700,7 +565,6 @@ const UI: React.FC<IUI> = ({ children }) => {
</BundleContainer>
</div>
</div>
</HotKeys>
);
};

View File

@ -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<IGlobalHotkeys> = ({ children }) => {
const hotkeys = useRef<HTMLDivElement | null>(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<HotKeys> = (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 (
<HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused>
{children}
</HotKeys>
);
};
export default GlobalHotkeys;