pl-fe: port new hotkeys component from mastodon
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -54,7 +54,6 @@
|
||||
"@lexical/rich-text": "^0.29.0",
|
||||
"@lexical/selection": "^0.29.0",
|
||||
"@lexical/utils": "^0.29.0",
|
||||
"@mkljczk/react-hotkeys": "^1.3.0",
|
||||
"@mkljczk/url-purify": "^0.0.3",
|
||||
"@reach/combobox": "^0.18.0",
|
||||
"@reach/rect": "^0.18.0",
|
||||
|
||||
@ -12,7 +12,7 @@ import Text from 'pl-fe/components/ui/text';
|
||||
import AccountContainer from 'pl-fe/containers/account-container';
|
||||
import Emojify from 'pl-fe/features/emoji/emojify';
|
||||
import StatusTypeIcon from 'pl-fe/features/status/components/status-type-icon';
|
||||
import { HotKeys } from 'pl-fe/features/ui/components/hotkeys';
|
||||
import { Hotkeys } from 'pl-fe/features/ui/components/hotkeys';
|
||||
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
|
||||
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
|
||||
import { useSettings } from 'pl-fe/hooks/use-settings';
|
||||
@ -328,23 +328,29 @@ const Status: React.FC<IStatus> = (props) => {
|
||||
);
|
||||
|
||||
if (filtered && actualStatus.showFiltered !== true) {
|
||||
const minHandlers = muted ? undefined : {
|
||||
const body = (
|
||||
<div className={clsx('status__wrapper text-center', { focusable })} ref={node}>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {filterResults.map(({ filter }) => filter.title).join(', ')}.
|
||||
{' '}
|
||||
<button className='text-primary-600 hover:underline dark:text-accent-blue' onClick={handleUnfilter}>
|
||||
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
|
||||
</button>
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (muted) return body;
|
||||
|
||||
const minHandlers = {
|
||||
moveUp: handleHotkeyMoveUp,
|
||||
moveDown: handleHotkeyMoveDown,
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeys handlers={minHandlers} attachRef={node}>
|
||||
<div className={clsx('status__wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {filterResults.map(({ filter }) => filter.title).join(', ')}.
|
||||
{' '}
|
||||
<button className='text-primary-600 hover:underline dark:text-accent-blue' onClick={handleUnfilter}>
|
||||
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
|
||||
</button>
|
||||
</Text>
|
||||
</div>
|
||||
</HotKeys>
|
||||
<Hotkeys handlers={minHandlers} focusable={focusable}>
|
||||
{body}
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
@ -356,7 +362,87 @@ const Status: React.FC<IStatus> = (props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const handlers = muted ? undefined : {
|
||||
const body = (
|
||||
<div
|
||||
className={clsx('status cursor-pointer', { focusable })}
|
||||
data-featured={featured ? 'true' : null}
|
||||
aria-label={textForScreenReader(intl, actualStatus, rebloggedByText)}
|
||||
ref={node}
|
||||
onClick={handleClick}
|
||||
role='link'
|
||||
>
|
||||
<Card
|
||||
variant={variant}
|
||||
className={clsx('status__wrapper space-y-4', className, `status-${actualStatus.visibility}`, {
|
||||
'py-6 sm:p-5': variant === 'rounded',
|
||||
'status-reply': !!status.in_reply_to_id,
|
||||
muted,
|
||||
read: unread === false,
|
||||
})}
|
||||
data-id={status.id}
|
||||
>
|
||||
{statusInfo}
|
||||
|
||||
<AccountContainer
|
||||
key={actualStatus.account_id}
|
||||
id={actualStatus.account_id}
|
||||
timestamp={actualStatus.created_at}
|
||||
timestampUrl={statusUrl}
|
||||
action={accountAction}
|
||||
hideActions={!accountAction}
|
||||
showEdit={!!actualStatus.edited_at}
|
||||
showAccountHoverCard={hoverable}
|
||||
withLinkToProfile={hoverable}
|
||||
approvalStatus={actualStatus.approval_status}
|
||||
avatarSize={avatarSize}
|
||||
items={(
|
||||
<>
|
||||
<StatusTypeIcon visibility={actualStatus.visibility} />
|
||||
<StatusLanguagePicker status={actualStatus} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='status__content-wrapper'>
|
||||
<StatusReplyMentions status={actualStatus} hoverable={hoverable} />
|
||||
|
||||
<Stack className='relative z-0'>
|
||||
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
|
||||
<StatusContent
|
||||
status={actualStatus}
|
||||
onClick={handleClick}
|
||||
collapsable
|
||||
translatable
|
||||
withMedia
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<StatusReactionsBar status={actualStatus} collapsed />
|
||||
|
||||
{!hideActionBar && (
|
||||
<div
|
||||
className={clsx({
|
||||
'pt-2': actualStatus.emoji_reactions.length,
|
||||
'pt-4': !actualStatus.emoji_reactions.length,
|
||||
})}
|
||||
>
|
||||
<StatusActionBar
|
||||
status={actualStatus}
|
||||
rebloggedBy={isReblog ? status.account : undefined}
|
||||
fromBookmarks={fromBookmarks}
|
||||
expandable
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div >
|
||||
);
|
||||
|
||||
if (muted) return body;
|
||||
|
||||
const handlers = {
|
||||
reply: handleHotkeyReply,
|
||||
favourite: handleHotkeyFavourite,
|
||||
boost: handleHotkeyBoost,
|
||||
@ -371,84 +457,9 @@ const Status: React.FC<IStatus> = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers} data-testid='status' attachRef={node}>
|
||||
<div
|
||||
className={clsx('status cursor-pointer', { focusable })}
|
||||
tabIndex={focusable && !muted ? 0 : undefined}
|
||||
data-featured={featured ? 'true' : null}
|
||||
aria-label={textForScreenReader(intl, actualStatus, rebloggedByText)}
|
||||
ref={node}
|
||||
onClick={handleClick}
|
||||
role='link'
|
||||
>
|
||||
<Card
|
||||
variant={variant}
|
||||
className={clsx('status__wrapper space-y-4', className, `status-${actualStatus.visibility}`, {
|
||||
'py-6 sm:p-5': variant === 'rounded',
|
||||
'status-reply': !!status.in_reply_to_id,
|
||||
muted,
|
||||
read: unread === false,
|
||||
})}
|
||||
data-id={status.id}
|
||||
>
|
||||
{statusInfo}
|
||||
|
||||
<AccountContainer
|
||||
key={actualStatus.account_id}
|
||||
id={actualStatus.account_id}
|
||||
timestamp={actualStatus.created_at}
|
||||
timestampUrl={statusUrl}
|
||||
action={accountAction}
|
||||
hideActions={!accountAction}
|
||||
showEdit={!!actualStatus.edited_at}
|
||||
showAccountHoverCard={hoverable}
|
||||
withLinkToProfile={hoverable}
|
||||
approvalStatus={actualStatus.approval_status}
|
||||
avatarSize={avatarSize}
|
||||
items={(
|
||||
<>
|
||||
<StatusTypeIcon visibility={actualStatus.visibility} />
|
||||
<StatusLanguagePicker status={actualStatus} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='status__content-wrapper'>
|
||||
<StatusReplyMentions status={actualStatus} hoverable={hoverable} />
|
||||
|
||||
<Stack className='relative z-0'>
|
||||
{actualStatus.event ? <EventPreview className='shadow-xl' status={actualStatus} /> : (
|
||||
<StatusContent
|
||||
status={actualStatus}
|
||||
onClick={handleClick}
|
||||
collapsable
|
||||
translatable
|
||||
withMedia
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<StatusReactionsBar status={actualStatus} collapsed />
|
||||
|
||||
{!hideActionBar && (
|
||||
<div
|
||||
className={clsx({
|
||||
'pt-2': actualStatus.emoji_reactions.length,
|
||||
'pt-4': !actualStatus.emoji_reactions.length,
|
||||
})}
|
||||
>
|
||||
<StatusActionBar
|
||||
status={actualStatus}
|
||||
rebloggedBy={isReblog ? status.account : undefined}
|
||||
fromBookmarks={fromBookmarks}
|
||||
expandable
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div >
|
||||
</HotKeys >
|
||||
<Hotkeys handlers={handlers} focusable={focusable} data-testid='status'>
|
||||
{body}
|
||||
</Hotkeys>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Text from 'pl-fe/components/ui/text';
|
||||
import { HotKeys } from 'pl-fe/features/ui/components/hotkeys';
|
||||
import { Hotkeys } from 'pl-fe/features/ui/components/hotkeys';
|
||||
|
||||
interface ITombstone {
|
||||
id: string;
|
||||
@ -13,28 +13,24 @@ interface ITombstone {
|
||||
|
||||
/** Represents a deleted item. */
|
||||
const Tombstone: React.FC<ITombstone> = ({ id, onMoveUp, onMoveDown, deleted }) => {
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handlers = {
|
||||
moveUp: () => onMoveUp?.(id),
|
||||
moveDown: () => onMoveDown?.(id),
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers} attachRef={node}>
|
||||
<div className='h-16'>
|
||||
<div
|
||||
className='focusable flex h-[42px] items-center justify-center rounded-lg border-2 border-gray-200 text-center dark:border-gray-800'
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text theme='muted'>
|
||||
{deleted
|
||||
? <FormattedMessage id='statuses.tombstone.deleted' defaultMessage='The post is deleted.' />
|
||||
: <FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts are unavailable.' />}
|
||||
</Text>
|
||||
</div>
|
||||
<Hotkeys handlers={handlers} className='h-16'>
|
||||
<div
|
||||
className='focusable flex h-[42px] items-center justify-center rounded-lg border-2 border-gray-200 text-center dark:border-gray-800'
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text theme='muted'>
|
||||
{deleted
|
||||
? <FormattedMessage id='statuses.tombstone.deleted' defaultMessage='The post is deleted.' />
|
||||
: <FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts are unavailable.' />}
|
||||
</Text>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -193,6 +193,7 @@ const ComposeEditor = React.forwardRef<LexicalEditor, IComposeEditor>(({
|
||||
},
|
||||
)}
|
||||
lang={language || undefined}
|
||||
data-compose-id={composeId}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import Text from 'pl-fe/components/ui/text';
|
||||
import AccountContainer from 'pl-fe/containers/account-container';
|
||||
import StatusContainer from 'pl-fe/containers/status-container';
|
||||
import Emojify from 'pl-fe/features/emoji/emojify';
|
||||
import { HotKeys } from 'pl-fe/features/ui/components/hotkeys';
|
||||
import { Hotkeys } from 'pl-fe/features/ui/components/hotkeys';
|
||||
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
|
||||
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
|
||||
import { useInstance } from 'pl-fe/hooks/use-instance';
|
||||
@ -431,14 +431,14 @@ const Notification: React.FC<INotification> = (props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers} data-testid='notification' attachRef={node}>
|
||||
<Hotkeys handlers={handlers} data-testid='notification'>
|
||||
<div
|
||||
className='notification focusable'
|
||||
className='notification'
|
||||
tabIndex={0}
|
||||
aria-label={ariaLabel}
|
||||
ref={node}
|
||||
>
|
||||
<div className='focusable p-4'>
|
||||
<div className='p-4'>
|
||||
<div className='mb-2'>
|
||||
<HStack alignItems='center' space={3}>
|
||||
<div
|
||||
@ -479,7 +479,7 @@ const Notification: React.FC<INotification> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import StatusActionBar from 'pl-fe/components/status-action-bar';
|
||||
import Tombstone from 'pl-fe/components/tombstone';
|
||||
import Stack from 'pl-fe/components/ui/stack';
|
||||
import PlaceholderStatus from 'pl-fe/features/placeholder/components/placeholder-status';
|
||||
import { HotKeys } from 'pl-fe/features/ui/components/hotkeys';
|
||||
import { Hotkeys } from 'pl-fe/features/ui/components/hotkeys';
|
||||
import PendingStatus from 'pl-fe/features/ui/components/pending-status';
|
||||
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
|
||||
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
|
||||
@ -380,10 +380,10 @@ const Thread = ({
|
||||
{status.deleted ? (
|
||||
<Tombstone id={status.id} onMoveUp={handleMoveUp} onMoveDown={handleMoveDown} deleted />
|
||||
) : (
|
||||
<HotKeys handlers={handlers} attachRef={statusRef}>
|
||||
<Hotkeys handlers={handlers}>
|
||||
<div
|
||||
ref={statusRef}
|
||||
className='focusable relative'
|
||||
className='relative'
|
||||
tabIndex={0}
|
||||
// FIXME: no "reblogged by" text is added for the screen reader
|
||||
aria-label={textForScreenReader(intl, status)}
|
||||
@ -403,7 +403,7 @@ const Thread = ({
|
||||
withLabels
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
)}
|
||||
|
||||
{hasDescendants && (
|
||||
|
||||
@ -1,14 +1,312 @@
|
||||
import { HotKeys as _HotKeys, type HotKeysProps } from '@mkljczk/react-hotkeys';
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
const isKeyboardEvent = (event: Event): event is KeyboardEvent => 'key' in event;
|
||||
|
||||
const normalizeKey = (key: string): string => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
switch (lowerKey) {
|
||||
case ' ':
|
||||
case 'spacebar': // for older browsers
|
||||
return 'space';
|
||||
|
||||
case 'arrowup':
|
||||
return 'up';
|
||||
case 'arrowdown':
|
||||
return 'down';
|
||||
case 'arrowleft':
|
||||
return 'left';
|
||||
case 'arrowright':
|
||||
return 'right';
|
||||
|
||||
case 'esc':
|
||||
case 'escape':
|
||||
return 'escape';
|
||||
|
||||
default:
|
||||
return lowerKey;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper component around `react-hotkeys`.
|
||||
* `react-hotkeys` is a legacy component, so confining its import to one place is beneficial.
|
||||
* In case of multiple hotkeys matching the pressed key(s),
|
||||
* the hotkey with a higher priority is selected. All others
|
||||
* are ignored.
|
||||
*/
|
||||
const HotKeys = React.forwardRef<any, HotKeysProps>(({ children, ...rest }, ref) => (
|
||||
<_HotKeys {...rest} ref={ref}>
|
||||
{children}
|
||||
</_HotKeys>
|
||||
));
|
||||
const hotkeyPriority = { singleKey: 0, combo: 1, sequence: 2 } as const;
|
||||
|
||||
export { HotKeys };
|
||||
/**
|
||||
* This type of function receives a keyboard event and an array of
|
||||
* previously pressed keys (within the last second), and returns
|
||||
* `isMatch` (whether the pressed keys match a hotkey) and `priority`
|
||||
* (a weighting used to resolve conflicts when two hotkeys match the
|
||||
* pressed keys)
|
||||
*/
|
||||
type KeyMatcher = (
|
||||
event: KeyboardEvent,
|
||||
bufferedKeys?: string[],
|
||||
) => {
|
||||
/**
|
||||
* Whether the event.key matches the hotkey
|
||||
*/
|
||||
isMatch: boolean;
|
||||
/**
|
||||
* If there are multiple matching hotkeys, the
|
||||
* first one with the highest priority will be handled
|
||||
*/
|
||||
priority: (typeof hotkeyPriority)[keyof typeof hotkeyPriority];
|
||||
};
|
||||
|
||||
/**
|
||||
* Matches a single key
|
||||
*/
|
||||
function just(keyName: string): KeyMatcher {
|
||||
return (event) => ({
|
||||
isMatch:
|
||||
normalizeKey(event.key) === keyName &&
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey,
|
||||
priority: hotkeyPriority.singleKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches any single key out of those provided
|
||||
*/
|
||||
function any(...keys: string[]): KeyMatcher {
|
||||
return (event) => ({
|
||||
isMatch: keys.some((keyName) => just(keyName)(event).isMatch),
|
||||
priority: hotkeyPriority.singleKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a single key combined with the option/alt modifier
|
||||
*/
|
||||
function optionPlus(key: string): KeyMatcher {
|
||||
return (event) => ({
|
||||
// Matching against event.code here as alt combos are often
|
||||
// mapped to other characters
|
||||
isMatch: event.altKey && event.code === `Key${key.toUpperCase()}`,
|
||||
priority: hotkeyPriority.combo,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches when all provided keys are pressed in sequence.
|
||||
*/
|
||||
function sequence(...sequence: string[]): KeyMatcher {
|
||||
return (event, bufferedKeys) => {
|
||||
const lastKeyInSequence = sequence.at(-1);
|
||||
const startOfSequence = sequence.slice(0, -1);
|
||||
const relevantBufferedKeys = bufferedKeys?.slice(-startOfSequence.length);
|
||||
|
||||
const bufferMatchesStartOfSequence =
|
||||
!!relevantBufferedKeys &&
|
||||
startOfSequence.join('') === relevantBufferedKeys.join('');
|
||||
|
||||
return {
|
||||
isMatch:
|
||||
bufferMatchesStartOfSequence &&
|
||||
normalizeKey(event.key) === lastKeyInSequence,
|
||||
priority: hotkeyPriority.sequence,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a map of all global hotkeys we support.
|
||||
* To trigger a hotkey, a handler with a matching name must be
|
||||
* provided to the `useHotkeys` hook or `Hotkeys` component.
|
||||
*/
|
||||
const hotkeyMatcherMap = {
|
||||
help: just('?'),
|
||||
search: any('s', '/'),
|
||||
back: just('backspace'),
|
||||
new: just('n'),
|
||||
forceNew: optionPlus('n'),
|
||||
// focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
||||
// focusLoadMore: just('l'),
|
||||
reply: just('r'),
|
||||
favourite: just('f'),
|
||||
boost: just('b'),
|
||||
quote: just('q'),
|
||||
mention: just('m'),
|
||||
react: just('e'),
|
||||
open: any('enter', 'o'),
|
||||
openProfile: just('p'),
|
||||
moveDown: any('down', 'j'),
|
||||
moveUp: any('up', 'k'),
|
||||
// toggleHidden: just('x'),
|
||||
toggleSensitive: any('h', 'x'),
|
||||
// toggleComposeSpoilers: optionPlus('x'),
|
||||
openMedia: just('e'),
|
||||
// onTranslate: just('t'),
|
||||
goToHome: sequence('g', 'h'),
|
||||
goToNotifications: sequence('g', 'n'),
|
||||
// goToLocal: sequence('g', 'l'),
|
||||
// goToFederated: sequence('g', 't'),
|
||||
// goToDirect: sequence('g', 'd'),
|
||||
// goToStart: sequence('g', 's'),
|
||||
goToFavourites: sequence('g', 'f'),
|
||||
// goToPinned: sequence('g', 'p'),
|
||||
goToProfile: sequence('g', 'u'),
|
||||
goToBlocked: sequence('g', 'b'),
|
||||
goToMuted: sequence('g', 'm'),
|
||||
goToRequests: sequence('g', 'r'),
|
||||
// cheat: sequence(
|
||||
// 'up',
|
||||
// 'up',
|
||||
// 'down',
|
||||
// 'down',
|
||||
// 'left',
|
||||
// 'right',
|
||||
// 'left',
|
||||
// 'right',
|
||||
// 'b',
|
||||
// 'a',
|
||||
// 'enter',
|
||||
// ),
|
||||
} as const;
|
||||
|
||||
type HotkeyName = keyof typeof hotkeyMatcherMap;
|
||||
|
||||
export type HandlerMap = Partial<
|
||||
Record<HotkeyName, (event: KeyboardEvent) => void>
|
||||
>;
|
||||
|
||||
export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
|
||||
const ref = useRef<T>(null);
|
||||
const bufferedKeys = useRef<string[]>([]);
|
||||
const sequenceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/**
|
||||
* Store the latest handlers object in a ref so we don't need to
|
||||
* add it as a dependency to the main event listener effect
|
||||
*/
|
||||
const handlersRef = useRef(handlers);
|
||||
useEffect(() => {
|
||||
handlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current ?? document;
|
||||
|
||||
function listener(event: Event) {
|
||||
// Ignore key presses from input, textarea, or select elements
|
||||
const tagName = (event.target as HTMLElement).tagName.toLowerCase();
|
||||
const shouldHandleEvent =
|
||||
isKeyboardEvent(event) &&
|
||||
!event.defaultPrevented &&
|
||||
!['input', 'textarea', 'select'].includes(tagName) &&
|
||||
!(
|
||||
['a', 'button'].includes(tagName) &&
|
||||
normalizeKey(event.key) === 'enter'
|
||||
);
|
||||
|
||||
if (shouldHandleEvent) {
|
||||
const matchCandidates: {
|
||||
handler: (event: KeyboardEvent) => void;
|
||||
priority: number;
|
||||
}[] = [];
|
||||
|
||||
(Object.keys(hotkeyMatcherMap) as HotkeyName[]).forEach(
|
||||
(handlerName) => {
|
||||
const handler = handlersRef.current[handlerName];
|
||||
|
||||
if (handler) {
|
||||
const hotkeyMatcher = hotkeyMatcherMap[handlerName];
|
||||
|
||||
const { isMatch, priority } = hotkeyMatcher(
|
||||
event,
|
||||
bufferedKeys.current,
|
||||
);
|
||||
|
||||
if (isMatch) {
|
||||
matchCandidates.push({ handler, priority });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Sort all matches by priority
|
||||
matchCandidates.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
const bestMatchingHandler = matchCandidates.at(0)?.handler;
|
||||
if (bestMatchingHandler) {
|
||||
bestMatchingHandler(event);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Add last keypress to buffer
|
||||
bufferedKeys.current.push(normalizeKey(event.key));
|
||||
|
||||
// Reset the timeout
|
||||
if (sequenceTimer.current) {
|
||||
clearTimeout(sequenceTimer.current);
|
||||
}
|
||||
sequenceTimer.current = setTimeout(() => {
|
||||
bufferedKeys.current = [];
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
element.addEventListener('keydown', listener);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('keydown', listener);
|
||||
if (sequenceTimer.current) {
|
||||
clearTimeout(sequenceTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
interface IHotkeys extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* An object containing functions to be run when a hotkey is pressed.
|
||||
* The key must be the name of a registered hotkey, e.g. "help" or "search"
|
||||
*/
|
||||
handlers: HandlerMap;
|
||||
/**
|
||||
* When enabled, hotkeys will be matched against the document root
|
||||
* rather than only inside of this component's DOM node.
|
||||
*/
|
||||
global?: boolean;
|
||||
/**
|
||||
* Allow the rendered `div` to be focused
|
||||
*/
|
||||
focusable?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hotkeys component allows us to globally register keyboard combinations
|
||||
* under a name and assign actions to them, either globally or scoped to a portion
|
||||
* of the app.
|
||||
*
|
||||
* ### How to use
|
||||
*
|
||||
* To add a new hotkey, add its key combination to the `hotkeyMatcherMap` object
|
||||
* and give it a name.
|
||||
*
|
||||
* Use the `<Hotkeys>` component or the `useHotkeys` hook in the part of of the app
|
||||
* where you want to handle the action, and pass in a handlers object.
|
||||
*
|
||||
* ```tsx
|
||||
* <Hotkeys handlers={{open: openStatus}} />
|
||||
* ```
|
||||
*
|
||||
* Now this function will be called when the 'open' hotkey is pressed by the user.
|
||||
*/
|
||||
export const Hotkeys: React.FC<IHotkeys> = ({ handlers, global, focusable = true, ...props }) => {
|
||||
const ref = useHotkeys<HTMLDivElement>(handlers);
|
||||
|
||||
return (
|
||||
<div ref={global ? undefined : ref} tabIndex={focusable ? -1 : undefined} {...props} className={clsx(props.className, focusable && 'focusable')} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { resetCompose } from 'pl-fe/actions/compose';
|
||||
@ -7,35 +7,35 @@ import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
|
||||
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
|
||||
import { useModalsStore } from 'pl-fe/stores/modals';
|
||||
|
||||
import { HotKeys } from '../components/hotkeys';
|
||||
import { Hotkeys } from '../components/hotkeys';
|
||||
|
||||
import type { LexicalEditor } from 'lexical';
|
||||
|
||||
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',
|
||||
goToProfile: ['g p', 'g u'],
|
||||
goToBlocked: 'g b',
|
||||
goToMuted: 'g m',
|
||||
goToRequests: 'g r',
|
||||
toggleSensitive: ['h', 'x'],
|
||||
openMedia: 'a',
|
||||
};
|
||||
// const keyMap = {
|
||||
// help: '?',
|
||||
// search: ['/', 's'],
|
||||
// back: 'backspace',
|
||||
// new: 'n',
|
||||
// forceNew: 'option+n',
|
||||
// reply: 'r',
|
||||
// favourite: 'f',
|
||||
// boost: 'b',
|
||||
// mention: 'm',
|
||||
// react: 'e',
|
||||
// open: ['enter', 'o'],
|
||||
// openProfile: 'p',
|
||||
// moveDown: ['down', 'j'],
|
||||
// moveUp: ['up', 'k'],
|
||||
// toggleSensitive: ['h', 'x'],
|
||||
// openMedia: 'a',
|
||||
// goToHome: 'g h',
|
||||
// goToNotifications: 'g n',
|
||||
// goToFavourites: 'g f',
|
||||
// goToProfile: ['g p', 'g u'],
|
||||
// goToBlocked: 'g b',
|
||||
// goToMuted: 'g m',
|
||||
// goToRequests: 'g r',
|
||||
// };
|
||||
|
||||
interface IGlobalHotkeys {
|
||||
children: React.ReactNode;
|
||||
@ -43,23 +43,11 @@ interface IGlobalHotkeys {
|
||||
}
|
||||
|
||||
const GlobalHotkeys: React.FC<IGlobalHotkeys> = ({ children, node }) => {
|
||||
const hotkeys = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const { account } = useOwnAccount();
|
||||
const { openModal } = useModalsStore();
|
||||
|
||||
const setHotkeysRef: React.LegacyRef<typeof HotKeys> = (c: any) => {
|
||||
hotkeys.current = c;
|
||||
|
||||
if (!account || !hotkeys.current) return;
|
||||
|
||||
// @ts-ignore
|
||||
hotkeys.current.__mousetrap__.stopCallback = (_e, element) =>
|
||||
['TEXTAREA', 'SELECT', 'INPUT', 'EM-EMOJI-PICKER'].includes(element.tagName) || !!element.closest('[contenteditable]');
|
||||
};
|
||||
|
||||
const handlers = useMemo(() => {
|
||||
const handleHotkeyNew = (e?: KeyboardEvent) => {
|
||||
e?.preventDefault();
|
||||
@ -159,9 +147,9 @@ const GlobalHotkeys: React.FC<IGlobalHotkeys> = ({ children, node }) => {
|
||||
}, [account?.id]);
|
||||
|
||||
return (
|
||||
<HotKeys keyMap={keyMap} handlers={handlers} ref={setHotkeysRef} attach={window} focused>
|
||||
<Hotkeys handlers={handlers} global>
|
||||
{children}
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -122,7 +122,7 @@ const HotkeysModal: React.FC<BaseModalProps> = ({ onClose }) => {
|
||||
label: <FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open likes list' />,
|
||||
},
|
||||
isLoggedIn && {
|
||||
key: <><Hotkey>g</Hotkey> + <Hotkey>p</Hotkey></>,
|
||||
key: <><Hotkey>g</Hotkey> + <Hotkey>u</Hotkey></>,
|
||||
label: <FormattedMessage id='keyboard_shortcuts.my_profile' defaultMessage='to open your profile' />,
|
||||
},
|
||||
isLoggedIn && {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
@ -17,7 +17,7 @@ import Stack from 'pl-fe/components/ui/stack';
|
||||
import Text from 'pl-fe/components/ui/text';
|
||||
import AccountContainer from 'pl-fe/containers/account-container';
|
||||
import { buildLink } from 'pl-fe/features/notifications/components/notification';
|
||||
import { HotKeys } from 'pl-fe/features/ui/components/hotkeys';
|
||||
import { Hotkeys } from 'pl-fe/features/ui/components/hotkeys';
|
||||
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
|
||||
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
|
||||
import { type MinifiedInteractionRequest, useAuthorizeInteractionRequestMutation, useFlatInteractionRequests, useRejectInteractionRequestMutation } from 'pl-fe/queries/statuses/use-interaction-requests';
|
||||
@ -96,8 +96,6 @@ const InteractionRequest: React.FC<IInteractionRequest> = ({
|
||||
const { account: ownAccount } = useOwnAccount();
|
||||
const { account } = useAccount(interactionRequest.account_id);
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { mutate: authorize } = useAuthorizeInteractionRequestMutation(interactionRequest.id);
|
||||
const { mutate: reject } = useRejectInteractionRequestMutation(interactionRequest.id);
|
||||
|
||||
@ -175,44 +173,42 @@ const InteractionRequest: React.FC<IInteractionRequest> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers} attachRef={node}>
|
||||
<div className='notification focusable' tabIndex={0}>
|
||||
<div className='focusable p-4'>
|
||||
<Stack space={2}>
|
||||
<div>
|
||||
<HStack alignItems='center' space={3}>
|
||||
<div
|
||||
className='flex justify-end'
|
||||
style={{ flexBasis: avatarSize }}
|
||||
>
|
||||
<Icon
|
||||
src={icons[interactionRequest.type]}
|
||||
className='flex-none text-primary-600 dark:text-primary-400'
|
||||
/>
|
||||
</div>
|
||||
<Hotkeys handlers={handlers} className='notification focusable' tabIndex={0}>
|
||||
<div className='focusable p-4'>
|
||||
<Stack space={2}>
|
||||
<div>
|
||||
<HStack alignItems='center' space={3}>
|
||||
<div
|
||||
className='flex justify-end'
|
||||
style={{ flexBasis: avatarSize }}
|
||||
>
|
||||
<Icon
|
||||
src={icons[interactionRequest.type]}
|
||||
className='flex-none text-primary-600 dark:text-primary-400'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='truncate'>
|
||||
<div className='truncate'>
|
||||
<Text theme='muted' size='xs' truncate>
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{interactionRequest.type !== 'reply' && (
|
||||
<div className='ml-auto'>
|
||||
<Text theme='muted' size='xs' truncate>
|
||||
{message}
|
||||
<RelativeTimestamp timestamp={interactionRequest.created_at} theme='muted' size='sm' className='whitespace-nowrap' />
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{interactionRequest.type !== 'reply' && (
|
||||
<div className='ml-auto'>
|
||||
<Text theme='muted' size='xs' truncate>
|
||||
<RelativeTimestamp timestamp={interactionRequest.created_at} theme='muted' size='sm' className='whitespace-nowrap' />
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{interactionRequest.status_id && <InteractionRequestStatus id={interactionRequest.status_id} hasReply={interactionRequest.type === 'reply'} actions={interactionRequest.reply_id ? undefined : actions} />}
|
||||
{interactionRequest.reply_id && <InteractionRequestStatus id={interactionRequest.reply_id} isReply actions={actions} />}
|
||||
</Stack>
|
||||
</div>
|
||||
{interactionRequest.status_id && <InteractionRequestStatus id={interactionRequest.status_id} hasReply={interactionRequest.type === 'reply'} actions={interactionRequest.reply_id ? undefined : actions} />}
|
||||
{interactionRequest.reply_id && <InteractionRequestStatus id={interactionRequest.reply_id} isReply actions={actions} />}
|
||||
</Stack>
|
||||
</div>
|
||||
</HotKeys>
|
||||
</Hotkeys>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user