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

View File

@ -27,6 +27,7 @@ interface ICard {
className?: string className?: string
/** Elements inside the card. */ /** Elements inside the card. */
children: React.ReactNode children: React.ReactNode
tabIndex?: number
} }
/** An opaque backdrop to hold a collection of related elements. */ /** 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 // @ts-ignore: it doesn't have types
import { ScrollContext } from 'react-router-scroll-4'; import { ScrollContext } from 'react-router-scroll-4';
import { loadInstance } from 'soapbox/actions/instance'; import { loadInstance } from 'soapbox/actions/instance';
import { fetchMe } from 'soapbox/actions/me'; import { fetchMe } from 'soapbox/actions/me';
import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox'; import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox';
@ -30,6 +29,7 @@ import {
OnboardingWizard, OnboardingWizard,
WaitlistPage, WaitlistPage,
} from 'soapbox/features/ui/util/async-components'; } from 'soapbox/features/ui/util/async-components';
import GlobalHotkeys from 'soapbox/features/ui/util/global-hotkeys';
import { createGlobals } from 'soapbox/globals'; import { createGlobals } from 'soapbox/globals';
import { import {
useAppSelector, useAppSelector,
@ -176,31 +176,33 @@ const SoapboxMount = () => {
<BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}> <BrowserRouter basename={BuildConfig.FE_SUBDIRECTORY}>
<CompatRouter> <CompatRouter>
<ScrollContext shouldUpdateScroll={shouldUpdateScroll}> <ScrollContext shouldUpdateScroll={shouldUpdateScroll}>
<Switch> <GlobalHotkeys>
<Route <Switch>
path='/embed/:statusId' <Route
render={(props) => <EmbeddedStatus params={props.match.params} />} path='/embed/:statusId'
/> render={(props) => <EmbeddedStatus params={props.match.params} />}
<Redirect from='/@:username/:statusId/embed' to='/embed/:statusId' /> />
<Redirect from='/@:username/:statusId/embed' to='/embed/:statusId' />
<Route> <Route>
{renderBody()} {renderBody()}
<BundleContainer fetchComponent={ModalContainer}> <BundleContainer fetchComponent={ModalContainer}>
{Component => <Component />} {Component => <Component />}
</BundleContainer> </BundleContainer>
<GdprBanner /> <GdprBanner />
<div id='toaster'> <div id='toaster'>
<Toaster <Toaster
position='top-right' position='top-right'
containerClassName='top-10' containerClassName='top-10'
containerStyle={{ top: 75 }} containerStyle={{ top: 75 }}
/> />
</div> </div>
</Route> </Route>
</Switch> </Switch>
</GlobalHotkeys>
</ScrollContext> </ScrollContext>
</CompatRouter> </CompatRouter>
</BrowserRouter> </BrowserRouter>

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { HotKeys } from 'react-hotkeys';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; 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 intl = useIntl();
const suggestedProfiles = useAppSelector((state) => state.suggestions.items); const suggestedProfiles = useAppSelector((state) => state.suggestions.items);
const isLoading = useAppSelector((state) => state.suggestions.isLoading); const isLoading = useAppSelector((state) => state.suggestions.isLoading);
if (!isLoading && suggestedProfiles.size === 0) return null; 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 ( return (
<Card size='lg' variant='rounded' className='space-y-6'> <HotKeys handlers={handlers}>
<HStack justifyContent='between' alignItems='center'> <Card size='lg' variant='rounded' className='focusable space-y-6' tabIndex={0}>
<CardTitle title={intl.formatMessage(messages.heading)} /> <HStack justifyContent='between' alignItems='center'>
<CardTitle title={intl.formatMessage(messages.heading)} />
<Link <Link
to='/suggestions' to='/suggestions'
className='text-primary-600 hover:underline dark:text-accent-blue' className='text-primary-600 hover:underline dark:text-accent-blue'
> >
{intl.formatMessage(messages.viewAll)} {intl.formatMessage(messages.viewAll)}
</Link> </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} />
))}
</HStack> </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) => { const _selectChild = (index: number) => {
if (!useWindowScroll) index = index + 1;
scroller.current?.scrollIntoView({ scroller.current?.scrollIntoView({
index, index,
behavior: 'smooth', behavior: 'smooth',
done: () => { done: () => {
const element = document.querySelector<HTMLDivElement>(`#thread [data-index="${index}"] .focusable`); node.current?.querySelector<HTMLDivElement>(`[data-index="${index}"] .focusable`)?.focus();
if (element) {
element.focus();
}
}, },
}); });
}; };

View File

@ -2,13 +2,11 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { HotKeys } from 'react-hotkeys';
import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom'; import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
import { fetchFollowRequests } from 'soapbox/actions/accounts'; import { fetchFollowRequests } from 'soapbox/actions/accounts';
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin'; import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
import { fetchAnnouncements } from 'soapbox/actions/announcements'; import { fetchAnnouncements } from 'soapbox/actions/announcements';
import { resetCompose } from 'soapbox/actions/compose';
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis'; import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
import { fetchFilters } from 'soapbox/actions/filters'; import { fetchFilters } from 'soapbox/actions/filters';
import { fetchMarker } from 'soapbox/actions/markers'; import { fetchMarker } from 'soapbox/actions/markers';
@ -155,34 +153,6 @@ const GroupMembershipRequestsSlug = withHoc(GroupMembershipRequests as any, Grou
const EmptyPage = HomePage; 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 { interface ISwitchingColumnsArea {
children: React.ReactNode children: React.ReactNode
} }
@ -398,7 +368,6 @@ const UI: React.FC<IUI> = ({ children }) => {
const userStream = useRef<any>(null); const userStream = useRef<any>(null);
const nostrStream = useRef<any>(null); const nostrStream = useRef<any>(null);
const node = useRef<HTMLDivElement | null>(null); const node = useRef<HTMLDivElement | null>(null);
const hotkeys = useRef<HTMLDivElement | null>(null);
const me = useAppSelector(state => state.me); const me = useAppSelector(state => state.me);
const { account } = useOwnAccount(); const { account } = useOwnAccount();
@ -529,91 +498,6 @@ const UI: React.FC<IUI> = ({ children }) => {
} }
}, [pendingPolicy, !!account]); }, [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 shouldHideFAB = (): boolean => {
const path = location.pathname; const path = location.pathname;
return Boolean(path.match(/^\/posts\/|^\/search|^\/getting-started|^\/chats/)); 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 // Wait for login to succeed or fail
if (me === null) return null; 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 = { const style: React.CSSProperties = {
pointerEvents: dropdownMenuIsOpen ? 'none' : undefined, pointerEvents: dropdownMenuIsOpen ? 'none' : undefined,
}; };
return ( return (
<HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused> <div ref={node} style={style}>
<div ref={node} style={style}> <div
<div className={clsx('pointer-events-none fixed z-[90] h-screen w-screen transition', {
className={clsx('pointer-events-none fixed z-[90] h-screen w-screen transition', { 'backdrop-blur': isDragging,
'backdrop-blur': isDragging, })}
})} />
/>
<BackgroundShapes /> <BackgroundShapes />
<div className='z-10 flex flex-col'> <div className='z-10 flex flex-col'>
<Navbar /> <Navbar />
<Layout> <Layout>
<Layout.Sidebar> <Layout.Sidebar>
{!standalone && <SidebarNavigation />} {!standalone && <SidebarNavigation />}
</Layout.Sidebar> </Layout.Sidebar>
<SwitchingColumnsArea> <SwitchingColumnsArea>
{children} {children}
</SwitchingColumnsArea> </SwitchingColumnsArea>
</Layout> </Layout>
{(me && !shouldHideFAB()) && ( {(me && !shouldHideFAB()) && (
<div className='fixed bottom-24 right-4 z-40 transition-all rtl:left-4 rtl:right-auto lg:hidden'> <div className='fixed bottom-24 right-4 z-40 transition-all rtl:left-4 rtl:right-auto lg:hidden'>
<FloatingActionButton /> <FloatingActionButton />
</div> </div>
)} )}
{me && ( {me && (
<BundleContainer fetchComponent={SidebarMenu}> <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}>
{Component => <Component />} {Component => <Component />}
</BundleContainer> </BundleContainer>
)}
<BundleContainer fetchComponent={StatusHoverCard}> {me && features.chats && (
{Component => <Component />} <BundleContainer fetchComponent={ChatWidget}>
{Component => (
<div className='hidden xl:block'>
<Component />
</div>
)}
</BundleContainer> </BundleContainer>
</div> )}
<ThumbNavigation />
<BundleContainer fetchComponent={ProfileHoverCard}>
{Component => <Component />}
</BundleContainer>
<BundleContainer fetchComponent={StatusHoverCard}>
{Component => <Component />}
</BundleContainer>
</div> </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;