diff --git a/packages/pl-fe/package.json b/packages/pl-fe/package.json index 450906d8a..e71e25394 100644 --- a/packages/pl-fe/package.json +++ b/packages/pl-fe/package.json @@ -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", diff --git a/packages/pl-fe/src/components/status.tsx b/packages/pl-fe/src/components/status.tsx index bcd873448..bf7b44ad7 100644 --- a/packages/pl-fe/src/components/status.tsx +++ b/packages/pl-fe/src/components/status.tsx @@ -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 = (props) => { ); if (filtered && actualStatus.showFiltered !== true) { - const minHandlers = muted ? undefined : { + const body = ( +
+ + : {filterResults.map(({ filter }) => filter.title).join(', ')}. + {' '} + + +
+ ); + + if (muted) return body; + + const minHandlers = { moveUp: handleHotkeyMoveUp, moveDown: handleHotkeyMoveDown, }; return ( - -
- - : {filterResults.map(({ filter }) => filter.title).join(', ')}. - {' '} - - -
-
+ + {body} + ); } @@ -356,7 +362,87 @@ const Status: React.FC = (props) => { ); } - const handlers = muted ? undefined : { + const body = ( +
+ + {statusInfo} + + + + + + )} + /> + +
+ + + + {actualStatus.event ? : ( + + )} + + + + + {!hideActionBar && ( +
+ +
+ )} +
+
+
+ ); + + if (muted) return body; + + const handlers = { reply: handleHotkeyReply, favourite: handleHotkeyFavourite, boost: handleHotkeyBoost, @@ -371,84 +457,9 @@ const Status: React.FC = (props) => { }; return ( - -
- - {statusInfo} - - - - - - )} - /> - -
- - - - {actualStatus.event ? : ( - - )} - - - - - {!hideActionBar && ( -
- -
- )} -
-
-
-
+ + {body} + ); }; diff --git a/packages/pl-fe/src/components/tombstone.tsx b/packages/pl-fe/src/components/tombstone.tsx index e8c7e8bfe..9fd123e8e 100644 --- a/packages/pl-fe/src/components/tombstone.tsx +++ b/packages/pl-fe/src/components/tombstone.tsx @@ -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 = ({ id, onMoveUp, onMoveDown, deleted }) => { - const node = useRef(null); - const handlers = { moveUp: () => onMoveUp?.(id), moveDown: () => onMoveDown?.(id), }; return ( - -
-
- - {deleted - ? - : } - -
+ +
+ + {deleted + ? + : } +
-
+ ); }; diff --git a/packages/pl-fe/src/features/compose/editor/index.tsx b/packages/pl-fe/src/features/compose/editor/index.tsx index a0ad936e5..fb0ca1b9b 100644 --- a/packages/pl-fe/src/features/compose/editor/index.tsx +++ b/packages/pl-fe/src/features/compose/editor/index.tsx @@ -193,6 +193,7 @@ const ComposeEditor = React.forwardRef(({ }, )} lang={language || undefined} + data-compose-id={composeId} />
} diff --git a/packages/pl-fe/src/features/notifications/components/notification.tsx b/packages/pl-fe/src/features/notifications/components/notification.tsx index 368bc5267..8a6a43275 100644 --- a/packages/pl-fe/src/features/notifications/components/notification.tsx +++ b/packages/pl-fe/src/features/notifications/components/notification.tsx @@ -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 = (props) => { ); return ( - +
-
+
= (props) => {
- + ); }; diff --git a/packages/pl-fe/src/features/status/components/thread.tsx b/packages/pl-fe/src/features/status/components/thread.tsx index 68bbbf6aa..5f5a28bdd 100644 --- a/packages/pl-fe/src/features/status/components/thread.tsx +++ b/packages/pl-fe/src/features/status/components/thread.tsx @@ -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 ? ( ) : ( - +
-
+
)} {hasDescendants && ( diff --git a/packages/pl-fe/src/features/ui/components/hotkeys.tsx b/packages/pl-fe/src/features/ui/components/hotkeys.tsx index 0be57b9ae..d01953c06 100644 --- a/packages/pl-fe/src/features/ui/components/hotkeys.tsx +++ b/packages/pl-fe/src/features/ui/components/hotkeys.tsx @@ -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(({ children, ...rest }, ref) => ( - <_HotKeys {...rest} ref={ref}> - {children} - -)); +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 void> +>; + +export function useHotkeys(handlers: HandlerMap) { + const ref = useRef(null); + const bufferedKeys = useRef([]); + const sequenceTimer = useRef | 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 { + /** + * 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 `` 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 + * + * ``` + * + * Now this function will be called when the 'open' hotkey is pressed by the user. + */ +export const Hotkeys: React.FC = ({ handlers, global, focusable = true, ...props }) => { + const ref = useHotkeys(handlers); + + return ( +
+ ); +}; diff --git a/packages/pl-fe/src/features/ui/util/global-hotkeys.tsx b/packages/pl-fe/src/features/ui/util/global-hotkeys.tsx index 1efa9198a..cc5a2ba72 100644 --- a/packages/pl-fe/src/features/ui/util/global-hotkeys.tsx +++ b/packages/pl-fe/src/features/ui/util/global-hotkeys.tsx @@ -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 = ({ children, node }) => { - const hotkeys = useRef(null); - const history = useHistory(); const dispatch = useAppDispatch(); const { account } = useOwnAccount(); const { openModal } = useModalsStore(); - const setHotkeysRef: React.LegacyRef = (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 = ({ children, node }) => { }, [account?.id]); return ( - + {children} - + ); }; diff --git a/packages/pl-fe/src/modals/hotkeys-modal.tsx b/packages/pl-fe/src/modals/hotkeys-modal.tsx index fba6d1e30..2db5a7739 100644 --- a/packages/pl-fe/src/modals/hotkeys-modal.tsx +++ b/packages/pl-fe/src/modals/hotkeys-modal.tsx @@ -122,7 +122,7 @@ const HotkeysModal: React.FC = ({ onClose }) => { label: , }, isLoggedIn && { - key: <>g + p, + key: <>g + u, label: , }, isLoggedIn && { diff --git a/packages/pl-fe/src/pages/status-lists/interaction-requests.tsx b/packages/pl-fe/src/pages/status-lists/interaction-requests.tsx index cca55df72..0e16e5407 100644 --- a/packages/pl-fe/src/pages/status-lists/interaction-requests.tsx +++ b/packages/pl-fe/src/pages/status-lists/interaction-requests.tsx @@ -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 = ({ const { account: ownAccount } = useOwnAccount(); const { account } = useAccount(interactionRequest.account_id); - const node = useRef(null); - const { mutate: authorize } = useAuthorizeInteractionRequestMutation(interactionRequest.id); const { mutate: reject } = useRejectInteractionRequestMutation(interactionRequest.id); @@ -175,44 +173,42 @@ const InteractionRequest: React.FC = ({ }; return ( - -
-
- -
- -
- -
+ +
+ +
+ +
+ +
-
+
+ + {message} + +
+ + {interactionRequest.type !== 'reply' && ( +
- {message} +
+ )} + +
- {interactionRequest.type !== 'reply' && ( -
- - - -
- )} -
-
- - {interactionRequest.status_id && } - {interactionRequest.reply_id && } -
-
+ {interactionRequest.status_id && } + {interactionRequest.reply_id && } +
- + ); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79055f532..45d6571e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,9 +163,6 @@ importers: '@lexical/utils': specifier: ^0.29.0 version: 0.29.0 - '@mkljczk/react-hotkeys': - specifier: ^1.3.0 - version: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mkljczk/url-purify': specifier: ^0.0.3 version: 0.0.3 @@ -1963,12 +1960,6 @@ packages: '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} - '@mkljczk/react-hotkeys@1.3.0': - resolution: {integrity: sha512-xfcYZ/J2YCpakRDEZd0ZdCQ2wx75Pmub9JksyBWX+W56UGDeMbmI8nPedG6V89JhUlgFsijZVVlVeDHIuzG6Hw==} - peerDependencies: - react: '>= 16.0.0' - react-dom: '>= 16.0.0' - '@mkljczk/url-purify@0.0.3': resolution: {integrity: sha512-3O4QO/nH9yV/GKim+yKvQF1cKWN0dBAsxC5Ve50d1PaUYQFSd4y733eRGt+zRcPZrvgyAkyZZ5Bx7dAHWX+bBQ==} @@ -4890,9 +4881,6 @@ packages: mlly@1.7.4: resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} - mousetrap@1.6.5: - resolution: {integrity: sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -8226,13 +8214,6 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} - '@mkljczk/react-hotkeys@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - lodash: 4.17.21 - mousetrap: 1.6.5 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - '@mkljczk/url-purify@0.0.3': {} '@napi-rs/wasm-runtime@0.2.12': @@ -11697,8 +11678,6 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 - mousetrap@1.6.5: {} - ms@2.1.3: {} muggle-string@0.4.1: {}