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,31 +176,33 @@ const SoapboxMount = () => {
<BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}>
<CompatRouter>
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
<Switch>
<Route
path='/embed/:statusId'
render={(props) => <EmbeddedStatus params={props.match.params} />}
/>
<Redirect from='/@:username/:statusId/embed' to='/embed/:statusId' />
<GlobalHotkeys>
<Switch>
<Route
path='/embed/:statusId'
render={(props) => <EmbeddedStatus params={props.match.params} />}
/>
<Redirect from='/@:username/:statusId/embed' to='/embed/:statusId' />
<Route>
{renderBody()}
<Route>
{renderBody()}
<BundleContainer fetchComponent={ModalContainer}>
{Component => <Component />}
</BundleContainer>
<BundleContainer fetchComponent={ModalContainer}>
{Component => <Component />}
</BundleContainer>
<GdprBanner />
<GdprBanner />
<div id='toaster'>
<Toaster
position='top-right'
containerClassName='top-10'
containerStyle={{ top: 75 }}
/>
</div>
</Route>
</Switch>
<div id='toaster'>
<Toaster
position='top-right'
containerClassName='top-10'
containerStyle={{ top: 75 }}
/>
</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,34 +62,59 @@ 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'>
<HStack justifyContent='between' alignItems='center'>
<CardTitle title={intl.formatMessage(messages.heading)} />
<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)} />
<Link
to='/suggestions'
className='text-primary-600 hover:underline dark:text-accent-blue'
>
{intl.formatMessage(messages.viewAll)}
</Link>
</HStack>
<CardBody>
<HStack space={4} alignItems='center' className='overflow-x-auto md:space-x-0 lg:overflow-x-hidden'>
{suggestedProfiles.slice(0, 4).map((suggestedProfile) => (
<SuggestionItem key={suggestedProfile.account} accountId={suggestedProfile.account} />
))}
<Link
to='/suggestions'
className='text-primary-600 hover:underline dark:text-accent-blue'
>
{intl.formatMessage(messages.viewAll)}
</Link>
</HStack>
</CardBody>
</Card>
<CardBody>
<HStack space={4} alignItems='center' className='overflow-x-auto md:space-x-0 lg:overflow-x-hidden'>
{suggestedProfiles.slice(0, 4).map((suggestedProfile) => (
<SuggestionItem key={suggestedProfile.account} accountId={suggestedProfile.account} />
))}
</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();
},
});
};
@ -465,4 +462,4 @@ const Thread = (props: IThread) => {
);
};
export default Thread;
export default Thread;

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,85 +506,65 @@ 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', {
'backdrop-blur': isDragging,
})}
/>
<div ref={node} style={style}>
<div
className={clsx('pointer-events-none fixed z-[90] h-screen w-screen transition', {
'backdrop-blur': isDragging,
})}
/>
<BackgroundShapes />
<BackgroundShapes />
<div className='z-10 flex flex-col'>
<Navbar />
<div className='z-10 flex flex-col'>
<Navbar />
<Layout>
<Layout.Sidebar>
{!standalone && <SidebarNavigation />}
</Layout.Sidebar>
<Layout>
<Layout.Sidebar>
{!standalone && <SidebarNavigation />}
</Layout.Sidebar>
<SwitchingColumnsArea>
{children}
</SwitchingColumnsArea>
</Layout>
<SwitchingColumnsArea>
{children}
</SwitchingColumnsArea>
</Layout>
{(me && !shouldHideFAB()) && (
<div className='fixed bottom-24 right-4 z-40 transition-all rtl:left-4 rtl:right-auto lg:hidden'>
<FloatingActionButton />
</div>
)}
{(me && !shouldHideFAB()) && (
<div className='fixed bottom-24 right-4 z-40 transition-all rtl:left-4 rtl:right-auto lg:hidden'>
<FloatingActionButton />
</div>
)}
{me && (
<BundleContainer fetchComponent={SidebarMenu}>
{Component => <Component />}
</BundleContainer>
)}
{me && features.chats && (
<BundleContainer fetchComponent={ChatWidget}>
{Component => (
<div className='hidden xl:block'>
<Component />
</div>
)}
</BundleContainer>
)}
<ThumbNavigation />
<BundleContainer fetchComponent={ProfileHoverCard}>
{me && (
<BundleContainer fetchComponent={SidebarMenu}>
{Component => <Component />}
</BundleContainer>
)}
<BundleContainer fetchComponent={StatusHoverCard}>
{Component => <Component />}
{me && features.chats && (
<BundleContainer fetchComponent={ChatWidget}>
{Component => (
<div className='hidden xl:block'>
<Component />
</div>
)}
</BundleContainer>
</div>
)}
<ThumbNavigation />
<BundleContainer fetchComponent={ProfileHoverCard}>
{Component => <Component />}
</BundleContainer>
<BundleContainer fetchComponent={StatusHoverCard}>
{Component => <Component />}
</BundleContainer>
</div>
</HotKeys>
</div>
);
};

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;