diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1850a45bbc..07400a07a4 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -23,7 +23,6 @@ matchManagers: ['npm'], matchPackageNames: [ 'tesseract.js', // Requires code changes - 'react-hotkeys', // Requires code changes // react-router: Requires manual upgrade 'history', diff --git a/Gemfile.lock b/Gemfile.lock index 1c187ac9df..094c9b1d6e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -438,7 +438,7 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0514) + mime-types-data (3.2025.0715) mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (5.25.5) @@ -610,7 +610,7 @@ GEM pg (1.5.9) pghero (3.7.0) activerecord (>= 7.1) - playwright-ruby-client (1.52.0) + playwright-ruby-client (1.54.0) concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) pp (0.6.2) @@ -722,7 +722,7 @@ GEM redlock (1.3.2) redis (>= 3.0.0, < 6.0) regexp_parser (2.10.0) - reline (0.6.1) + reline (0.6.2) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) diff --git a/app/javascript/flavours/glitch/components/hotkeys/index.tsx b/app/javascript/flavours/glitch/components/hotkeys/index.tsx new file mode 100644 index 0000000000..b5e0de4c59 --- /dev/null +++ b/app/javascript/flavours/glitch/components/hotkeys/index.tsx @@ -0,0 +1,282 @@ +import { useEffect, useRef } from 'react'; + +import { normalizeKey, isKeyboardEvent } from './utils'; + +/** + * In case of multiple hotkeys matching the pressed key(s), + * the hotkey with a higher priority is selected. All others + * are ignored. + */ +const hotkeyPriority = { + singleKey: 0, + combo: 1, + sequence: 2, +} as const; + +/** + * 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, + 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'), + reply: just('r'), + favourite: just('f'), + boost: just('b'), + mention: just('m'), + open: any('enter', 'o'), + openProfile: just('p'), + moveDown: any('down', 'j'), + moveUp: any('up', 'k'), + toggleHidden: just('x'), + toggleSensitive: just('h'), + 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; +} + +/** + * 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<{ + /** + * 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; +}> = ({ handlers, global, focusable = true, children }) => { + const ref = useHotkeys(handlers); + + return ( +
+ {children} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/hotkeys/utils.ts b/app/javascript/flavours/glitch/components/hotkeys/utils.ts new file mode 100644 index 0000000000..1430e1685b --- /dev/null +++ b/app/javascript/flavours/glitch/components/hotkeys/utils.ts @@ -0,0 +1,29 @@ +export function isKeyboardEvent(event: Event): event is KeyboardEvent { + return 'key' in event; +} + +export function 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; + } +} diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 4070ea2913..609c7694d9 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -7,8 +7,7 @@ import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { HotKeys } from 'react-hotkeys'; - +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { ContentWarning } from 'flavours/glitch/components/content_warning'; import { PictureInPicturePlaceholder } from 'flavours/glitch/components/picture_in_picture_placeholder'; import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning'; @@ -33,7 +32,6 @@ import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; import StatusIcons from './status_icons'; import StatusPrepend from './status_prepend'; - const domParser = new DOMParser(); export const textForScreenReader = (intl, status, rebloggedByText = false, expanded = false) => { @@ -499,13 +497,13 @@ class Status extends ImmutablePureComponent { if (hidden) { return ( - +
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('spoiler_text').length > 0 && ({status.get('spoiler_text')})} {expanded && {status.get('content')}}
-
+
); } @@ -516,7 +514,7 @@ class Status extends ImmutablePureComponent { }; return ( - +
: {matchedFilters.join(', ')}. {' '} @@ -524,7 +522,7 @@ class Status extends ImmutablePureComponent {
-
+
); } @@ -678,7 +676,7 @@ class Status extends ImmutablePureComponent { const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); return ( - +
-
+
); } diff --git a/app/javascript/flavours/glitch/components/status_list.jsx b/app/javascript/flavours/glitch/components/status_list.jsx index 95ff8f45e5..3323c6faae 100644 --- a/app/javascript/flavours/glitch/components/status_list.jsx +++ b/app/javascript/flavours/glitch/components/status_list.jsx @@ -57,7 +57,7 @@ export default class StatusList extends ImmutablePureComponent { const elementIndex = this.getCurrentStatusIndex(id, featured) - 1; this._selectChild(elementIndex, true); }; - + handleMoveDown = (id, featured) => { const elementIndex = this.getCurrentStatusIndex(id, featured) + 1; this._selectChild(elementIndex, false); @@ -70,6 +70,7 @@ export default class StatusList extends ImmutablePureComponent { _selectChild (index, align_top) { const container = this.node.node; + // TODO: This breaks at the inline-follow-suggestions container const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); if (element) { diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx index 96e252ad2f..e29f6b218d 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx @@ -100,16 +100,38 @@ class ComposeForm extends ImmutablePureComponent { this.props.onChange(e.target.value); }; - handleKeyDown = (e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + blurOnEscape = (e) => { + if (['esc', 'escape'].includes(e.key.toLowerCase())) { + e.target.blur(); + } + } + + handleKeyDownPost = (e) => { + if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) { this.handleSubmit(e); } - if (e.keyCode === 13 && e.altKey) { + if (e.key.toLowerCase() === 'enter' && e.altKey) { this.handleSecondarySubmit(e); } + + this.blurOnEscape(e); }; + handleKeyDownSpoiler = (e) => { + if (e.key.toLowerCase() === 'enter') { + if (e.ctrlKey || e.metaKey) { + this.handleSubmit(); + } else if (e.altKey) { + this.handleSecondarySubmit(e); + } else { + e.preventDefault(); + this.textareaRef.current?.focus(); + } + } + this.blurOnEscape(e); + } + getFulltextForCharacterCounting = () => { return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join(''); }; @@ -265,7 +287,7 @@ class ComposeForm extends ImmutablePureComponent { value={this.props.spoilerText} disabled={isSubmitting} onChange={this.handleChangeSpoilerText} - onKeyDown={this.handleKeyDown} + onKeyDown={this.handleKeyDownSpoiler} ref={this.setSpoilerText} suggestions={this.props.suggestions} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} @@ -290,7 +312,7 @@ class ComposeForm extends ImmutablePureComponent { onChange={this.handleChange} suggestions={this.props.suggestions} onFocus={this.handleFocus} - onKeyDown={this.handleKeyDown} + onKeyDown={this.handleKeyDownPost} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionSelected={this.onSuggestionSelected} diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx index 48aec3f12f..85066ecf09 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx @@ -10,15 +10,13 @@ import { createSelector } from '@reduxjs/toolkit'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { useDispatch, useSelector } from 'react-redux'; - -import { HotKeys } from 'react-hotkeys'; - import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import { replyCompose } from 'flavours/glitch/actions/compose'; import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations'; import { openModal } from 'flavours/glitch/actions/modal'; import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'flavours/glitch/actions/statuses'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import AttachmentList from 'flavours/glitch/components/attachment_list'; import AvatarComposite from 'flavours/glitch/components/avatar_composite'; import { IconButton } from 'flavours/glitch/components/icon_button'; @@ -178,7 +176,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) }; return ( - +
@@ -228,7 +226,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
-
+
); }; diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.jsx b/app/javascript/flavours/glitch/features/notifications/components/notification.jsx index a2ed584135..cebb589691 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notification.jsx +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.jsx @@ -8,13 +8,13 @@ import { withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { HotKeys } from 'react-hotkeys'; import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react'; import PersonIcon from '@/material-icons/400-24px/person-fill.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react'; import { Account } from 'flavours/glitch/components/account'; import { Icon } from 'flavours/glitch/components/icon'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { Permalink } from 'flavours/glitch/components/permalink'; import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; @@ -121,7 +121,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -133,7 +133,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -141,7 +141,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -153,7 +153,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -313,7 +313,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
-
+
); } @@ -336,7 +336,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
-
+
); } @@ -352,7 +352,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -364,7 +364,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -391,7 +391,7 @@ class Notification extends ImmutablePureComponent { ); return ( - +
@@ -403,7 +403,7 @@ class Notification extends ImmutablePureComponent {
- + ); } diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx index 9bd6c27a86..f4a9013a70 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group.tsx @@ -1,9 +1,8 @@ import { useMemo } from 'react'; -import { HotKeys } from 'react-hotkeys'; - import { navigateToProfile } from 'flavours/glitch/actions/accounts'; import { mentionComposeById } from 'flavours/glitch/actions/compose'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import type { NotificationGroup as NotificationGroupModel } from 'flavours/glitch/models/notification_group'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; @@ -156,5 +155,5 @@ export const NotificationGroup: React.FC<{ return null; } - return {content}; + return {content}; }; diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group_with_status.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group_with_status.tsx index db12019f8e..774de6e54d 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group_with_status.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_group_with_status.tsx @@ -3,12 +3,11 @@ import type { JSX } from 'react'; import classNames from 'classnames'; -import { HotKeys } from 'react-hotkeys'; - import { replyComposeById } from 'flavours/glitch/actions/compose'; import { navigateToStatus } from 'flavours/glitch/actions/statuses'; import { Avatar } from 'flavours/glitch/components/avatar'; import { AvatarGroup } from 'flavours/glitch/components/avatar_group'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import type { IconProp } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon'; import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; @@ -91,7 +90,7 @@ export const NotificationGroupWithStatus: React.FC<{ ); return ( - +
-
+
); }; diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_with_status.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_with_status.tsx index 9f0ead6888..371b141f55 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_with_status.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_with_status.tsx @@ -2,8 +2,6 @@ import { useMemo } from 'react'; import classNames from 'classnames'; -import { HotKeys } from 'react-hotkeys'; - import { replyComposeById } from 'flavours/glitch/actions/compose'; import { toggleReblog, @@ -13,6 +11,7 @@ import { navigateToStatus, toggleStatusSpoilers, } from 'flavours/glitch/actions/statuses'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import type { IconProp } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon'; import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted'; @@ -87,7 +86,7 @@ export const NotificationWithStatus: React.FC<{ if (!statusId || isFiltered) return null; return ( - +
-
+
); }; diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index 37cf4c9be5..da04de0cc7 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -10,11 +10,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; -import { HotKeys } from 'react-hotkeys'; - import ChatIcon from '@/material-icons/400-24px/chat.svg?react'; import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { Icon } from 'flavours/glitch/components/icon'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { TimelineHint } from 'flavours/glitch/components/timeline_hint'; @@ -647,7 +646,7 @@ class Status extends ImmutablePureComponent {
{ancestors} - +
-
+
{descendants} {remoteHint} diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index 0069fb603f..617d2a6d8e 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -10,13 +10,13 @@ import { connect } from 'react-redux'; import Favico from 'favico.js'; import { debounce } from 'lodash'; -import { HotKeys } from 'react-hotkeys'; import { focusApp, unfocusApp, changeLayout } from 'flavours/glitch/actions/app'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; import { fetchNotifications } from 'flavours/glitch/actions/notification_groups'; import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding'; import { AlertsController } from 'flavours/glitch/components/alerts_controller'; +import { Hotkeys } from 'flavours/glitch/components/hotkeys'; import { HoverCardController } from 'flavours/glitch/components/hover_card_controller'; import { Permalink } from 'flavours/glitch/components/permalink'; import { PictureInPicture } from 'flavours/glitch/features/picture_in_picture'; @@ -106,41 +106,6 @@ const mapStateToProps = state => ({ username: state.getIn(['accounts', me, 'username']), }); -const keyMap = { - help: '?', - new: 'n', - search: ['s', '/'], - forceNew: 'option+n', - toggleComposeSpoilers: 'option+x', - focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], - reply: 'r', - favourite: 'f', - boost: 'b', - mention: 'm', - open: ['enter', 'o'], - openProfile: 'p', - moveDown: ['down', 'j'], - moveUp: ['up', 'k'], - back: 'backspace', - goToHome: 'g h', - goToNotifications: 'g n', - goToLocal: 'g l', - goToFederated: 'g t', - goToDirect: 'g d', - goToStart: 'g s', - goToFavourites: 'g f', - goToPinned: 'g p', - goToProfile: 'g u', - goToBlocked: 'g b', - goToMuted: 'g m', - goToRequests: 'g r', - toggleHidden: 'x', - bookmark: 'd', - toggleSensitive: 'h', - openMedia: 'e', - onTranslate: 't', -}; - class SwitchingColumnsArea extends PureComponent { static propTypes = { identity: identityContextPropShape, @@ -416,6 +381,10 @@ class UI extends PureComponent { } }; + handleDonate = () => { + location.href = 'https://joinmastodon.org/sponsors#donate' + } + componentDidMount () { const { signedIn } = this.props.identity; @@ -443,10 +412,6 @@ class UI extends PureComponent { setTimeout(() => this.props.dispatch(fetchServer()), 3000); } - this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); - }; - if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support this.visibilityHiddenProp = 'hidden'; this.visibilityChange = 'visibilitychange'; @@ -556,10 +521,6 @@ class UI extends PureComponent { } }; - setHotkeysRef = c => { - this.hotkeys = c; - }; - handleHotkeyToggleHelp = () => { if (this.props.location.pathname === '/keyboard-shortcuts') { this.props.history.goBack(); @@ -647,10 +608,11 @@ class UI extends PureComponent { goToBlocked: this.handleHotkeyGoToBlocked, goToMuted: this.handleHotkeyGoToMuted, goToRequests: this.handleHotkeyGoToRequests, + cheat: this.handleDonate, }; return ( - +
{moved && (
- + ); } diff --git a/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx b/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx new file mode 100644 index 0000000000..b95c9410e1 --- /dev/null +++ b/app/javascript/mastodon/components/hotkeys/hotkeys.stories.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; + +import type { HandlerMap } from '.'; +import { Hotkeys } from '.'; + +const meta = { + title: 'Components/Hotkeys', + component: Hotkeys, + args: { + global: undefined, + focusable: undefined, + handlers: {}, + }, + tags: ['test'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => { + async function confirmHotkey(name: string, shouldFind = true) { + // 'status' is the role of the 'output' element + const output = await canvas.findByRole('status'); + if (shouldFind) { + await expect(output).toHaveTextContent(name); + } else { + await expect(output).not.toHaveTextContent(name); + } + } + + const button = await canvas.findByRole('button'); + await userEvent.click(button); + + await userEvent.keyboard('n'); + await confirmHotkey('new'); + + await userEvent.keyboard('/'); + await confirmHotkey('search'); + + await userEvent.keyboard('o'); + await confirmHotkey('open'); + + await userEvent.keyboard('{Alt>}N{/Alt}'); + await confirmHotkey('forceNew'); + + await userEvent.keyboard('gh'); + await confirmHotkey('goToHome'); + + await userEvent.keyboard('gn'); + await confirmHotkey('goToNotifications'); + + await userEvent.keyboard('gf'); + await confirmHotkey('goToFavourites'); + + /** + * Ensure that hotkeys are not triggered when certain + * interactive elements are focused: + */ + + await userEvent.keyboard('{enter}'); + await confirmHotkey('open', false); + + const input = await canvas.findByRole('textbox'); + await userEvent.click(input); + + await userEvent.keyboard('n'); + await confirmHotkey('new', false); + + await userEvent.keyboard('{backspace}'); + await confirmHotkey('None', false); + + /** + * Reset playground: + */ + + await userEvent.click(button); + await userEvent.keyboard('{backspace}'); +}; + +export const Default = { + render: function Render() { + const [matchedHotkey, setMatchedHotkey] = useState( + null, + ); + + const handlers = { + back: () => { + setMatchedHotkey(null); + }, + new: () => { + setMatchedHotkey('new'); + }, + forceNew: () => { + setMatchedHotkey('forceNew'); + }, + search: () => { + setMatchedHotkey('search'); + }, + open: () => { + setMatchedHotkey('open'); + }, + goToHome: () => { + setMatchedHotkey('goToHome'); + }, + goToNotifications: () => { + setMatchedHotkey('goToNotifications'); + }, + goToFavourites: () => { + setMatchedHotkey('goToFavourites'); + }, + }; + + return ( + +
+

+ Hotkey playground +

+

+ Last pressed hotkey: {matchedHotkey ?? 'None'} +

+

+ Click within the dashed border and press the "n + " or "/" key. Press " + Backspace" to clear the displayed hotkey. +

+

+ Try typing a sequence, like "g" shortly + followed by "h", "n", or + "f" +

+

+ Note that this playground doesn't support all hotkeys we use in + the app. +

+

+ When a is focused, " + Enter + " should not trigger "open", but "o + " should. +

+

+ When an input element is focused, hotkeys should not interfere with + regular typing: +

+ +
+
+ ); + }, + play: hotkeyTest, +}; diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx new file mode 100644 index 0000000000..b5e0de4c59 --- /dev/null +++ b/app/javascript/mastodon/components/hotkeys/index.tsx @@ -0,0 +1,282 @@ +import { useEffect, useRef } from 'react'; + +import { normalizeKey, isKeyboardEvent } from './utils'; + +/** + * In case of multiple hotkeys matching the pressed key(s), + * the hotkey with a higher priority is selected. All others + * are ignored. + */ +const hotkeyPriority = { + singleKey: 0, + combo: 1, + sequence: 2, +} as const; + +/** + * 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, + 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'), + reply: just('r'), + favourite: just('f'), + boost: just('b'), + mention: just('m'), + open: any('enter', 'o'), + openProfile: just('p'), + moveDown: any('down', 'j'), + moveUp: any('up', 'k'), + toggleHidden: just('x'), + toggleSensitive: just('h'), + 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; +} + +/** + * 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<{ + /** + * 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; +}> = ({ handlers, global, focusable = true, children }) => { + const ref = useHotkeys(handlers); + + return ( +
+ {children} +
+ ); +}; diff --git a/app/javascript/mastodon/components/hotkeys/utils.ts b/app/javascript/mastodon/components/hotkeys/utils.ts new file mode 100644 index 0000000000..1430e1685b --- /dev/null +++ b/app/javascript/mastodon/components/hotkeys/utils.ts @@ -0,0 +1,29 @@ +export function isKeyboardEvent(event: Event): event is KeyboardEvent { + return 'key' in event; +} + +export function 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; + } +} diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 29fd4234dd..171ae780ff 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -8,10 +8,9 @@ import { Link } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { HotKeys } from 'react-hotkeys'; - import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { ContentWarning } from 'mastodon/components/content_warning'; import { FilterWarning } from 'mastodon/components/filter_warning'; import { Icon } from 'mastodon/components/icon'; @@ -35,7 +34,6 @@ import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; import { StatusThreadLabel } from './status_thread_label'; import { VisibilityIcon } from './visibility_icon'; - const domParser = new DOMParser(); export const textForScreenReader = (intl, status, rebloggedByText = false) => { @@ -325,11 +323,11 @@ class Status extends ImmutablePureComponent { }; handleHotkeyMoveUp = e => { - this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured')); + this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured')); }; handleHotkeyMoveDown = e => { - this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured')); + this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured')); }; handleHotkeyToggleHidden = () => { @@ -437,13 +435,13 @@ class Status extends ImmutablePureComponent { if (hidden) { return ( - +
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('spoiler_text').length > 0 && ({status.get('spoiler_text')})} {expanded && {status.get('content')}}
-
+
); } @@ -543,7 +541,7 @@ class Status extends ImmutablePureComponent { const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); return ( - +
{!skipPrepend && prepend} @@ -604,7 +602,7 @@ class Status extends ImmutablePureComponent { }
-
+
); } diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index 390659e9b6..cca449b0ca 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -56,7 +56,7 @@ export default class StatusList extends ImmutablePureComponent { const elementIndex = this.getCurrentStatusIndex(id, featured) - 1; this._selectChild(elementIndex, true); }; - + handleMoveDown = (id, featured) => { const elementIndex = this.getCurrentStatusIndex(id, featured) + 1; this._selectChild(elementIndex, false); @@ -69,6 +69,7 @@ export default class StatusList extends ImmutablePureComponent { _selectChild (index, align_top) { const container = this.node.node; + // TODO: This breaks at the inline-follow-suggestions container const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); if (element) { diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 6dd3dbd054..5bc77c4bcd 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -92,10 +92,29 @@ class ComposeForm extends ImmutablePureComponent { this.props.onChange(e.target.value); }; - handleKeyDown = (e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.handleSubmit(); + blurOnEscape = (e) => { + if (['esc', 'escape'].includes(e.key.toLowerCase())) { + e.target.blur(); } + } + + handleKeyDownPost = (e) => { + if (e.key.toLowerCase() === 'enter' && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + this.blurOnEscape(e); + }; + + handleKeyDownSpoiler = (e) => { + if (e.key.toLowerCase() === 'enter') { + if (e.ctrlKey || e.metaKey) { + this.handleSubmit(); + } else { + e.preventDefault(); + this.textareaRef.current?.focus(); + } + } + this.blurOnEscape(e); }; getFulltextForCharacterCounting = () => { @@ -248,7 +267,7 @@ class ComposeForm extends ImmutablePureComponent { value={this.props.spoilerText} disabled={isSubmitting} onChange={this.handleChangeSpoilerText} - onKeyDown={this.handleKeyDown} + onKeyDown={this.handleKeyDownSpoiler} ref={this.setSpoilerText} suggestions={this.props.suggestions} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} @@ -273,7 +292,7 @@ class ComposeForm extends ImmutablePureComponent { onChange={this.handleChange} suggestions={this.props.suggestions} onFocus={this.handleFocus} - onKeyDown={this.handleKeyDown} + onKeyDown={this.handleKeyDownPost} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionSelected={this.onSuggestionSelected} diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx index c27cd3727f..ec3621f0c0 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx @@ -10,15 +10,13 @@ import { createSelector } from '@reduxjs/toolkit'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { useDispatch, useSelector } from 'react-redux'; - -import { HotKeys } from 'react-hotkeys'; - import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import { replyCompose } from 'mastodon/actions/compose'; import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations'; import { openModal } from 'mastodon/actions/modal'; import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import AttachmentList from 'mastodon/components/attachment_list'; import AvatarComposite from 'mastodon/components/avatar_composite'; import { IconButton } from 'mastodon/components/icon_button'; @@ -169,7 +167,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) }; return ( - +
@@ -219,7 +217,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
- + ); }; diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx index 86431f62fd..b38e5da159 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.jsx +++ b/app/javascript/mastodon/features/notifications/components/notification.jsx @@ -8,7 +8,6 @@ import { Link, withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { HotKeys } from 'react-hotkeys'; import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react'; @@ -20,6 +19,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import { Account } from 'mastodon/components/account'; import { Icon } from 'mastodon/components/icon'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import { me } from 'mastodon/initial_state'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; @@ -137,7 +137,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -149,7 +149,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -157,7 +157,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -169,7 +169,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -195,7 +195,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -217,7 +217,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -225,7 +225,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -247,7 +247,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -259,7 +259,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
@@ -282,7 +282,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -294,7 +294,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
@@ -317,7 +317,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -331,7 +331,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
@@ -358,7 +358,7 @@ class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} />
- + ); } @@ -371,7 +371,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
-
+
); } @@ -394,7 +394,7 @@ class Notification extends ImmutablePureComponent { } return ( - +
-
+
); } @@ -410,7 +410,7 @@ class Notification extends ImmutablePureComponent { const { intl, unread } = this.props; return ( - +
@@ -422,7 +422,7 @@ class Notification extends ImmutablePureComponent {
- + ); } @@ -438,7 +438,7 @@ class Notification extends ImmutablePureComponent { const targetLink = ; return ( - +
@@ -450,7 +450,7 @@ class Notification extends ImmutablePureComponent {
- + ); } diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx index d5eb851985..f0f2139ad2 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx @@ -1,9 +1,8 @@ import { useMemo } from 'react'; -import { HotKeys } from 'react-hotkeys'; - import { navigateToProfile } from 'mastodon/actions/accounts'; import { mentionComposeById } from 'mastodon/actions/compose'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; @@ -156,5 +155,5 @@ export const NotificationGroup: React.FC<{ return null; } - return {content}; + return {content}; }; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx index e7ed8792f6..4be1eefcdd 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group_with_status.tsx @@ -3,12 +3,11 @@ import type { JSX } from 'react'; import classNames from 'classnames'; -import { HotKeys } from 'react-hotkeys'; - import { replyComposeById } from 'mastodon/actions/compose'; import { navigateToStatus } from 'mastodon/actions/statuses'; import { Avatar } from 'mastodon/components/avatar'; import { AvatarGroup } from 'mastodon/components/avatar_group'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import type { IconProp } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; @@ -91,7 +90,7 @@ export const NotificationGroupWithStatus: React.FC<{ ); return ( - +
-
+
); }; diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx index de484322fb..96a4a4d65d 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx @@ -2,14 +2,13 @@ import { useMemo } from 'react'; import classNames from 'classnames'; -import { HotKeys } from 'react-hotkeys'; - import { replyComposeById } from 'mastodon/actions/compose'; import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions'; import { navigateToStatus, toggleStatusSpoilers, } from 'mastodon/actions/statuses'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import type { IconProp } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon'; import { StatusQuoteManager } from 'mastodon/components/status_quoted'; @@ -83,7 +82,7 @@ export const NotificationWithStatus: React.FC<{ if (!statusId || isFiltered) return null; return ( - +
-
+
); }; diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 0f02e7b50f..64cd0c4f82 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -10,10 +10,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; -import { HotKeys } from 'react-hotkeys'; - import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { Icon } from 'mastodon/components/icon'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { TimelineHint } from 'mastodon/components/timeline_hint'; @@ -616,7 +615,7 @@ class Status extends ImmutablePureComponent {
{ancestors} - +
-
+
{descendants} {remoteHint} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index c9834eb0a4..e8eef704ef 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -9,13 +9,13 @@ import { Redirect, Route, withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; -import { HotKeys } from 'react-hotkeys'; import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; import { fetchNotifications } from 'mastodon/actions/notification_groups'; import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; import { AlertsController } from 'mastodon/components/alerts_controller'; +import { Hotkeys } from 'mastodon/components/hotkeys'; import { HoverCardController } from 'mastodon/components/hover_card_controller'; import { PictureInPicture } from 'mastodon/features/picture_in_picture'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; @@ -98,40 +98,6 @@ const mapStateToProps = state => ({ username: state.getIn(['accounts', me, 'username']), }); -const keyMap = { - help: '?', - new: 'n', - search: ['s', '/'], - forceNew: 'option+n', - toggleComposeSpoilers: 'option+x', - focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'], - reply: 'r', - favourite: 'f', - boost: 'b', - mention: 'm', - open: ['enter', 'o'], - openProfile: 'p', - moveDown: ['down', 'j'], - moveUp: ['up', 'k'], - back: 'backspace', - goToHome: 'g h', - goToNotifications: 'g n', - goToLocal: 'g l', - goToFederated: 'g t', - goToDirect: 'g d', - goToStart: 'g s', - goToFavourites: 'g f', - goToPinned: 'g p', - goToProfile: 'g u', - goToBlocked: 'g b', - goToMuted: 'g m', - goToRequests: 'g r', - toggleHidden: 'x', - toggleSensitive: 'h', - openMedia: 'e', - onTranslate: 't', -}; - class SwitchingColumnsArea extends PureComponent { static propTypes = { identity: identityContextPropShape, @@ -400,6 +366,10 @@ class UI extends PureComponent { } }; + handleDonate = () => { + location.href = 'https://joinmastodon.org/sponsors#donate' + } + componentDidMount () { const { signedIn } = this.props.identity; @@ -426,10 +396,6 @@ class UI extends PureComponent { setTimeout(() => this.props.dispatch(fetchServer()), 3000); } - - this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); - }; } componentWillUnmount () { @@ -509,10 +475,6 @@ class UI extends PureComponent { } }; - setHotkeysRef = c => { - this.hotkeys = c; - }; - handleHotkeyToggleHelp = () => { if (this.props.location.pathname === '/keyboard-shortcuts') { this.props.history.goBack(); @@ -593,10 +555,11 @@ class UI extends PureComponent { goToBlocked: this.handleHotkeyGoToBlocked, goToMuted: this.handleHotkeyGoToMuted, goToRequests: this.handleHotkeyGoToRequests, + cheat: this.handleDonate, }; return ( - +
{children} @@ -611,7 +574,7 @@ class UI extends PureComponent {
-
+
); } diff --git a/app/javascript/mastodon/locales/pa.json b/app/javascript/mastodon/locales/pa.json index 8ab4eb2252..5236d246c0 100644 --- a/app/javascript/mastodon/locales/pa.json +++ b/app/javascript/mastodon/locales/pa.json @@ -1,8 +1,12 @@ { "about.contact": "ਸੰਪਰਕ:", + "about.default_locale": "ਮੂਲ", + "about.disclaimer": "ਮਸਟੋਡੋਨ ਇੱਕ ਆਜ਼ਾਦ, ਖੁੱਲ੍ਹੇ ਸਰੋਤ ਵਾਲਾ ਸਾਫਟਵੇਅਰ ਹੈ ਅਤੇ Mastodon gGmbH ਦਾ ਮਾਰਕਾ ਹੈ।", "about.domain_blocks.no_reason_available": "ਕਾਰਨ ਮੌਜੂਦ ਨਹੀਂ ਹੈ", "about.domain_blocks.silenced.title": "ਸੀਮਿਤ", - "about.domain_blocks.suspended.title": "ਮੁਅੱਤਲ ਕੀਤੀ", + "about.domain_blocks.suspended.title": "ਸਸਪੈਂਡ ਕੀਤਾ", + "about.language_label": "ਭਾਸ਼ਾ", + "about.not_available": "ਇਹ ਜਾਣਕਾਰੀ ਨੂੰ ਇਸ ਸਰਵਰ ਉੱਤੇ ਉਪਲੱਬਧ ਨਹੀਂ ਕੀਤਾ ਗਿਆ ਹੈ।", "about.rules": "ਸਰਵਰ ਨਿਯਮ", "account.account_note_header": "ਨਿੱਜੀ ਨੋਟ", "account.add_or_remove_from_list": "ਸੂਚੀ ਵਿੱਚ ਜੋੜੋ ਜਾਂ ਹਟਾਓ", @@ -12,21 +16,33 @@ "account.block_domain": "{domain} ਡੋਮੇਨ ਉੱਤੇ ਪਾਬੰਦੀ ਲਾਓ", "account.block_short": "ਪਾਬੰਦੀ", "account.blocked": "ਪਾਬੰਦੀਸ਼ੁਦਾ", + "account.blocking": "ਪਾਬੰਦੀ ਲਾਉਣੀ", "account.cancel_follow_request": "ਫ਼ਾਲੋ ਕਰਨ ਨੂੰ ਰੱਦ ਕਰੋ", "account.copy": "ਪਰੋਫਾਇਲ ਲਈ ਲਿੰਕ ਕਾਪੀ ਕਰੋ", "account.direct": "ਨਿੱਜੀ ਜ਼ਿਕਰ @{name}", + "account.disable_notifications": "ਜਦੋਂ {name} ਕੋਈ ਪੋਸਟ ਕਰੇ ਤਾਂ ਮੈਨੂੰ ਸੂਚਨਾ ਨਾ ਦਿਓ", + "account.domain_blocking": "ਡੋਮੇਨ ਉੱਤੇ ਪਾਬੰਦੀ", "account.edit_profile": "ਪਰੋਫਾਈਲ ਨੂੰ ਸੋਧੋ", "account.enable_notifications": "ਜਦੋਂ {name} ਪੋਸਟ ਕਰੇ ਤਾਂ ਮੈਨੂੰ ਸੂਚਨਾ ਦਿਓ", "account.endorse": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਫ਼ੀਚਰ", + "account.familiar_followers_one": "{name1} ਵਲੋਂ ਫ਼ਾਲੋ ਕੀਤਾ", + "account.familiar_followers_two": "{name1} ਅਤੇ {name2} ਵਲੋਂ ਫ਼ਾਲੋ ਕੀਤਾ", + "account.featured": "ਫ਼ੀਚਰ", + "account.featured.accounts": "ਪਰੋਫਾਈਲ", + "account.featured.hashtags": "ਹੈਸ਼ਟੈਗ", "account.featured_tags.last_status_at": "{date} ਨੂੰ ਆਖਰੀ ਪੋਸਟ", "account.featured_tags.last_status_never": "ਕੋਈ ਪੋਸਟ ਨਹੀਂ", "account.follow": "ਫ਼ਾਲੋ", "account.follow_back": "ਵਾਪਸ ਫਾਲ਼ੋ ਕਰੋ", "account.followers": "ਫ਼ਾਲੋਅਰ", "account.followers.empty": "ਇਸ ਵਰਤੋਂਕਾਰ ਨੂੰ ਹਾਲੇ ਕੋਈ ਫ਼ਾਲੋ ਨਹੀਂ ਕਰਦਾ ਹੈ।", + "account.followers_counter": "{count, plural, one {{counter} ਫ਼ਾਲੋਅਰ} other {{counter} ਫ਼ਾਲੋਅਰ}}", + "account.followers_you_know_counter": "{counter} ਤੁਸੀਂ ਜਾਣਦੇ ਹੋ", "account.following": "ਫ਼ਾਲੋ ਕੀਤਾ", "account.follows.empty": "ਇਹ ਵਰਤੋਂਕਾਰ ਹਾਲੇ ਕਿਸੇ ਨੂੰ ਫ਼ਾਲੋ ਨਹੀਂ ਕਰਦਾ ਹੈ।", + "account.follows_you": "ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਦੇ ਹਨ", "account.go_to_profile": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਜਾਓ", + "account.hide_reblogs": "{name} ਵਲੋਂ ਬੂਸਟ ਨੂੰ ਲੁਕਾਓ", "account.joined_short": "ਜੁਆਇਨ ਕੀਤਾ", "account.media": "ਮੀਡੀਆ", "account.mention": "@{name} ਦਾ ਜ਼ਿਕਰ", @@ -34,6 +50,7 @@ "account.mute_notifications_short": "ਨੋਟਫਿਕੇਸ਼ਨਾਂ ਨੂੰ ਮੌਨ ਕਰੋ", "account.mute_short": "ਮੌਨ ਕਰੋ", "account.muted": "ਮੌਨ ਕੀਤੀਆਂ", + "account.mutual": "ਤੁਸੀਂ ਇੱਕ ਦੂਜੇ ਨੂੰ ਫ਼ਾਲੋ ਕਰਦੇ ਹੋ", "account.no_bio": "ਕੋਈ ਵਰਣਨ ਨਹੀਂ ਦਿੱਤਾ।", "account.open_original_page": "ਅਸਲ ਸਫ਼ੇ ਨੂੰ ਖੋਲ੍ਹੋ", "account.posts": "ਪੋਸਟਾਂ", @@ -42,8 +59,10 @@ "account.requested": "ਮਨਜ਼ੂਰੀ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ। ਫ਼ਾਲੋ ਬੇਨਤੀਆਂ ਨੂੰ ਰੱਦ ਕਰਨ ਲਈ ਕਲਿੱਕ ਕਰੋ", "account.requested_follow": "{name} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਨ ਦੀ ਬੇਨਤੀ ਕੀਤੀ ਹੈ", "account.share": "{name} ਦਾ ਪਰੋਫ਼ਾਇਲ ਸਾਂਝਾ ਕਰੋ", + "account.statuses_counter": "{count, plural, one {{counter} ਪੋਸਟ} other {{counter} ਪੋਸਟਾਂ}}", "account.unblock": "@{name} ਤੋਂ ਪਾਬੰਦੀ ਹਟਾਓ", "account.unblock_domain": "{domain} ਡੋਮੇਨ ਤੋਂ ਪਾਬੰਦੀ ਹਟਾਓ", + "account.unblock_domain_short": "ਪਾਬੰਦੀ ਹਟਾਓ", "account.unblock_short": "ਪਾਬੰਦੀ ਹਟਾਓ", "account.unendorse": "ਪਰੋਫਾਇਲ ਉੱਤੇ ਫ਼ੀਚਰ ਨਾ ਕਰੋ", "account.unfollow": "ਅਣ-ਫ਼ਾਲੋ", @@ -148,6 +167,8 @@ "confirmations.missing_alt_text.secondary": "ਕਿਵੇਂ ਵੀ ਪੋਸਟ ਕਰੋ", "confirmations.mute.confirm": "ਮੌਨ ਕਰੋ", "confirmations.redraft.confirm": "ਹਟਾਓ ਤੇ ਮੁੜ-ਡਰਾਫਟ", + "confirmations.remove_from_followers.confirm": "ਫ਼ਾਲੋਅਰ ਨੂੰ ਹਟਾਓ", + "confirmations.remove_from_followers.title": "ਫ਼ਾਲੋਅਰ ਨੂੰ ਹਟਾਉਣਾ ਹੈ?", "confirmations.unfollow.confirm": "ਅਣ-ਫ਼ਾਲੋ", "confirmations.unfollow.message": "ਕੀ ਤੁਸੀਂ {name} ਨੂੰ ਅਣ-ਫ਼ਾਲੋ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?", "confirmations.unfollow.title": "ਵਰਤੋਂਕਾਰ ਨੂੰ ਅਣ-ਫ਼ਾਲੋ ਕਰਨਾ ਹੈ?", @@ -182,7 +203,9 @@ "emoji_button.custom": "ਕਸਟਮ", "emoji_button.flags": "ਝੰਡੀਆਂ", "emoji_button.food": "ਖਾਣਾ-ਪੀਣਾ", + "emoji_button.label": "ਇਮੋਜੀ ਪਾਓ", "emoji_button.nature": "ਕੁਦਰਤ", + "emoji_button.not_found": "ਕੋਈ ਮਿਲਦਾ ਇਮੋਜ਼ੀ ਨਹੀਂ ਲੱਭਿਆ", "emoji_button.objects": "ਇਕਾਈ", "emoji_button.people": "ਲੋਕ", "emoji_button.recent": "ਅਕਸਰ ਵਰਤੇ", @@ -199,9 +222,15 @@ "empty_column.list": "ਇਸ ਸੂਚੀ ਵਿੱਚ ਹਾਲੇ ਕੁਝ ਵੀ ਨਹੀਂ ਹੈ। ਜਦੋਂ ਇਸ ਸੂਚੀ ਦੇ ਮੈਂਬਰ ਨਵੀਆਂ ਪੋਸਟਾਂ ਪਾਉਂਦੇ ਹਨ ਤਾਂ ਉਹ ਇੱਥੇ ਦਿਖਾਈ ਦੇਣਗੀਆਂ।", "errors.unexpected_crash.report_issue": "ਮੁੱਦੇ ਦੀ ਰਿਪੋਰਟ ਕਰੋ", "explore.suggested_follows": "ਲੋਕ", + "explore.title": "ਰੁਝਾਨ", "explore.trending_links": "ਖ਼ਬਰਾਂ", "explore.trending_statuses": "ਪੋਸਟਾਂ", "explore.trending_tags": "ਹੈਸ਼ਟੈਗ", + "featured_carousel.header": "{count, plural, one {ਟੰਗੀ ਹੋਈ ਪੋਸਟ} other {ਟੰਗੀਆਂ ਹੋਈਆਂ ਪੋਸਟਾਂ}}", + "featured_carousel.next": "ਅੱਗੇ", + "featured_carousel.post": "ਪੋਸਟ", + "featured_carousel.previous": "ਪਿੱਛੇ", + "featured_carousel.slide": "{total} ਵਿੱਚੋਂ {index}", "filter_modal.added.expired_title": "ਫਿਲਟਰ ਦੀ ਮਿਆਦ ਪੁੱਗੀ!", "filter_modal.added.review_and_configure_title": "ਫਿਲਟਰ ਸੈਟਿੰਗਾਂ", "filter_modal.added.settings_link": "ਸੈਟਿੰਗਾਂ ਸਫ਼ਾ", @@ -252,6 +281,8 @@ "home.column_settings.show_replies": "ਜਵਾਬਾਂ ਨੂੰ ਵੇਖੋ", "home.hide_announcements": "ਐਲਾਨਾਂ ਨੂੰ ਓਹਲੇ ਕਰੋ", "home.pending_critical_update.link": "ਅੱਪਡੇਟ ਵੇਖੋ", + "home.pending_critical_update.title": "ਗੰਭੀਰ ਸੁਰੱਖਿਆ ਅੱਪਡੇਟ ਮੌਜੂਦ ਹੈ!", + "home.show_announcements": "ਐਲਾਨਾਂ ਨੂੰ ਵੇਖਾਓ", "ignore_notifications_modal.ignore": "ਨੋਟਫਿਕੇਸ਼ਨਾਂ ਨੂੰ ਅਣਡਿੱਠਾ ਕਰੋ", "info_button.label": "ਮਦਦ", "interaction_modal.go": "ਜਾਓ", @@ -332,9 +363,12 @@ "media_gallery.hide": "ਲੁਕਾਓ", "mute_modal.hide_from_notifications": "ਨੋਟੀਫਿਕੇਸ਼ਨਾਂ ਵਿੱਚੋਂ ਲੁਕਾਓ", "mute_modal.show_options": "ਚੋਣਾਂ ਨੂੰ ਵੇਖਾਓ", + "mute_modal.title": "ਵਰਤੋਂਕਾਰ ਨੂੰ ਮੌਨ ਕਰਨਾ ਹੈ?", "navigation_bar.about": "ਇਸ ਬਾਰੇ", + "navigation_bar.account_settings": "ਪਾਸਵਰਡ ਅਤੇ ਸੁਰੱਖਿਆ", "navigation_bar.administration": "ਪਰਸ਼ਾਸ਼ਨ", "navigation_bar.advanced_interface": "ਤਕਨੀਕੀ ਵੈੱਬ ਇੰਟਰਫੇਸ ਵਿੱਚ ਖੋਲ੍ਹੋ", + "navigation_bar.automated_deletion": "ਆਪਣੇ-ਆਪ ਹਟਾਈ ਪੋਸਟ", "navigation_bar.blocks": "ਪਾਬੰਦੀ ਲਾਏ ਵਰਤੋਂਕਾਰ", "navigation_bar.bookmarks": "ਬੁੱਕਮਾਰਕ", "navigation_bar.direct": "ਨਿੱਜੀ ਜ਼ਿਕਰ", @@ -346,11 +380,16 @@ "navigation_bar.follows_and_followers": "ਫ਼ਾਲੋ ਅਤੇ ਫ਼ਾਲੋ ਕਰਨ ਵਾਲੇ", "navigation_bar.lists": "ਸੂਚੀਆਂ", "navigation_bar.logout": "ਲਾਗ ਆਉਟ", + "navigation_bar.more": "ਹੋਰ", "navigation_bar.mutes": "ਮੌਨ ਕੀਤੇ ਵਰਤੋਂਕਾਰ", "navigation_bar.preferences": "ਪਸੰਦਾਂ", + "navigation_bar.privacy_and_reach": "ਪਰਦੇਦਾਰੀ ਅਤੇ ਪਹੁੰਚ", "navigation_bar.search": "ਖੋਜੋ", + "navigation_bar.search_trends": "ਖੋਜ / ਰੁਝਾਨ", "not_signed_in_indicator.not_signed_in": "ਇਹ ਸਰੋਤ ਵਰਤਣ ਲਈ ਤੁਹਾਨੂੰ ਲਾਗਇਨ ਕਰਨ ਦੀ ਲੋੜ ਹੈ।", "notification.admin.sign_up": "{name} ਨੇ ਸਾਈਨ ਅੱਪ ਕੀਤਾ", + "notification.favourite": "{name} ਨੇ ਤੁਹਾਡੀ ਪੋਸਟ ਨੂੰ ਪਸੰਦ ਕੀਤਾ", + "notification.favourite_pm": "{name} ਨੇ ਤੁਹਾਡੇ ਨਿੱਜੀ ਜ਼ਿਕਰ ਨੂੰ ਪਸੰਦ ਕੀਤਾ", "notification.follow": "{name} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕੀਤਾ", "notification.follow.name_and_others": "{name} ਅਤੇ {count, plural, one {# ਹੋਰ} other {# ਹੋਰਾਂ}} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕੀਤਾ", "notification.follow_request": "{name} ਨੇ ਤੁਹਾਨੂੰ ਫ਼ਾਲੋ ਕਰਨ ਦੀ ਬੇਨਤੀ ਕੀਤੀ ਹੈ", @@ -365,6 +404,7 @@ "notification.moderation_warning.action_silence": "ਤੁਹਾਡੇ ਖਾਤੇ ਨੂੰ ਸੀਮਿਤ ਕੀਤਾ ਗਿਆ ਹੈ।", "notification.moderation_warning.action_suspend": "ਤੁਹਾਡੇ ਖਾਤੇ ਨੂੰ ਮੁਅੱਤਲ ਕੀਤਾ ਗਿਆ ਹੈ।", "notification.reblog": "{name} boosted your status", + "notification.relationships_severance_event": "{name} ਨਾਲ ਕਨੈਕਸ਼ਨ ਗੁਆਚੇ", "notification.relationships_severance_event.learn_more": "ਹੋਰ ਜਾਣੋ", "notification.status": "{name} ਨੇ ਹੁਣੇ ਪੋਸਟ ਕੀਤਾ", "notification.update": "{name} ਨੋ ਪੋਸਟ ਨੂੰ ਸੋਧਿਆ", @@ -540,7 +580,10 @@ "status.unpin": "ਪਰੋਫਾਈਲ ਤੋਂ ਲਾਹੋ", "subscribed_languages.save": "ਤਬਦੀਲੀਆਂ ਸੰਭਾਲੋ", "tabs_bar.home": "ਘਰ", + "tabs_bar.menu": "ਮੇਨੂ", "tabs_bar.notifications": "ਸੂਚਨਾਵਾਂ", + "tabs_bar.publish": "ਨਵੀਂ ਪੋਸਟ", + "tabs_bar.search": "ਖੋਜੋ", "terms_of_service.title": "ਸੇਵਾ ਦੀਆਂ ਸ਼ਰਤਾਂ", "time_remaining.days": "{number, plural, one {# ਦਿਨ} other {# ਦਿਨ}} ਬਾਕੀ", "time_remaining.hours": "{number, plural, one {# ਘੰਟਾ} other {# ਘੰਟੇ}} ਬਾਕੀ", @@ -561,6 +604,9 @@ "video.expand": "ਵੀਡੀਓ ਨੂੰ ਫੈਲਾਓ", "video.fullscreen": "ਪੂਰੀ ਸਕਰੀਨ", "video.hide": "ਵੀਡੀਓ ਨੂੰ ਲੁਕਾਓ", + "video.mute": "ਮੌਨ", "video.pause": "ਠਹਿਰੋ", - "video.play": "ਚਲਾਓ" + "video.play": "ਚਲਾਓ", + "video.volume_down": "ਅਵਾਜ਼ ਘਟਾਓ", + "video.volume_up": "ਅਵਾਜ਼ ਵਧਾਓ" } diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 57958076a7..8928f253b1 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -537,8 +537,10 @@ "mute_modal.you_wont_see_mentions": "你看不到提及对方的嘟文。", "mute_modal.you_wont_see_posts": "对方可以看到你的嘟文,但是你看不到对方的。", "navigation_bar.about": "关于", + "navigation_bar.account_settings": "密码与安全", "navigation_bar.administration": "管理", "navigation_bar.advanced_interface": "在高级网页界面中打开", + "navigation_bar.automated_deletion": "自动删除嘟文", "navigation_bar.blocks": "已屏蔽的用户", "navigation_bar.bookmarks": "书签", "navigation_bar.direct": "私下提及", diff --git a/config/locales/et.yml b/config/locales/et.yml index dba31f4ffd..962f6be9db 100644 --- a/config/locales/et.yml +++ b/config/locales/et.yml @@ -318,6 +318,8 @@ et: new: create: Loo teadaanne title: Uus teadaanne + preview: + title: Info teavituse üle vaatamine publish: Postita published_msg: Teadaande avaldamine õnnestus! scheduled_for: Kavandatud ajaks %{time} @@ -485,6 +487,7 @@ et: request_body: Päringu sisu providers: active: Aktiivne + base_url: Baas-URL callback: Pöördliiklus delete: Kustuta edit: Muuda teenusepakkujat @@ -499,6 +502,7 @@ et: reject: Keeldu title: Kinnita FASP-i registreerimine save: Salvesta + select_capabilities: Vali oskused sign_in: Logi sisse status: Olek title: Täiendavad teenusepakkujad Födiversumis (FASP - Fediverse Auxiliary Service Providers) diff --git a/config/locales/ga.yml b/config/locales/ga.yml index c9e943c91c..111ae9c56f 100644 --- a/config/locales/ga.yml +++ b/config/locales/ga.yml @@ -1220,9 +1220,9 @@ ga: confirmation_dialogs: Dialóga deimhnithe discovery: Fionnachtain localization: - body: Aistríonn oibrithe deonacha Mastodon. + body: Oibrithe deonacha a dhéanann aistriúchán Mastodon. guide_link: https://crowdin.com/project/mastodon - guide_link_text: Is féidir le gach duine rannchuidiú. + guide_link_text: Is féidir le gach duine cur leis. sensitive_content: Ábhar íogair application_mailer: notification_preferences: Athraigh roghanna ríomhphoist diff --git a/config/locales/nan.yml b/config/locales/nan.yml index 28c98a58d6..354ef8b0f8 100644 --- a/config/locales/nan.yml +++ b/config/locales/nan.yml @@ -454,6 +454,7 @@ nan: title: 封鎖新ê電子phue網域 no_email_domain_block_selected: 因為無揀任何電子phue域名封鎖,所以lóng無改變 not_permitted: 無允准 + resolved_dns_records_hint_html: 域名解析做下kha ê MX域名,tsiah ê域名上後負責收電子phue。封鎖MX域名ē封任何有siâng款MX域名ê電子郵件ê註冊,就算通看見ê域名無kâng,mā án-ne。Tio̍h細膩,m̄通封鎖主要ê電子phue提供者。 resolved_through_html: 通過 %{domain} 解析 title: 封鎖ê電子phue網域 export_domain_allows: @@ -468,9 +469,78 @@ nan: private_comment_template: 佇 %{date} tuì %{source} 輸入 title: 輸入域名封鎖 invalid_domain_block: 因為下kha ê錯誤,làng過tsi̍t ê以上ê域名封鎖:%{error} + new: + title: 輸入域名封鎖 + no_file: Iáu bē揀檔案 + fasp: + debug: + callbacks: + created_at: 建立佇 + delete: Thâi掉 + ip: IP地址 + request_body: 請求主文 + title: 除蟲callback + providers: + active: 有效 + base_url: 基本URL + callback: Callback + delete: Thâi掉 + edit: 編輯提供者 + finish_registration: 完成註冊 + name: 名 + providers: 提供者 + public_key_fingerprint: 公開鎖匙ê指頭仔螺(public key fingerprint) + registration_requested: 註冊請求ah + registrations: + confirm: 確認 + description: Lí收著FASP ê註冊ah。nā準lí bô啟動,請拒絕。若有啟動,請佇確認註冊以前,細膩比較名kap鎖匙ê指頭仔螺。 + reject: 拒絕 + title: 確認FASP註冊 + save: 儲存 + select_capabilities: 揀功能 + sign_in: 登入 + status: 狀態 + title: 聯邦宇宙輔助服務提供者 (FASP) + title: FASP + follow_recommendations: + description_html: "跟tuè建議幫tsān新用者緊tshuē著心適ê內容。Nā使用者無hām別lâng有夠額ê互動,來形成個人化ê跟tuè建議,就ē推薦tsiah ê口座。In是佇指定語言內底,由最近上tsia̍p參與ê,kap上tsē lâng跟tuè ê口座,用ta̍k kang做基礎,相濫koh計算出來ê。" + language: 揀語言 + status: 狀態 + suppress: Khàm掉跟tuè建議 + suppressed: Khàm掉ê + title: 跟tuè建議 + unsuppress: 恢復跟tuè建議 instances: + audit_log: + title: 最近ê審核日誌 + view_all: 看完整ê審核日誌 + availability: + description_html: + other: Nā佇 %{count} kang內,寄送kàu hit ê域名lóng失敗,除非收著hit ê域名來ê寄送,a̍h無buē koh試寄送。 + failure_threshold_reached: 佇 %{date} kàu失敗ê底限。 + failures_recorded: + other: 連suà %{count} kang lóng寄失敗。 + no_failures_recorded: 報告內底無失敗。 + title: 可用性 + warning: 頂kái試連接tsit臺服侍器是無成功 + back_to_all: 全部 + back_to_limited: 受限制 + back_to_warning: 警告 + by_domain: 域名 + confirm_purge: Lí kám確定beh永永thâi掉tsit ê域名來ê資料? + content_policies: + comment: 內部ê筆記 + description_html: Lí ē當定義用tī所有tuì tsit ê域名kap伊ê子域名來ê口座ê內容政策。 dashboard: instance_languages_dimension: Tsia̍p用ê語言 + invites: + filter: + available: 通用ê + expired: 過期ê + title: 過濾器 + title: 邀請 + ip_blocks: + add_new: 建立規則 statuses: language: 語言 trends: diff --git a/config/locales/pa.yml b/config/locales/pa.yml index 1899d71008..f9508f9b9a 100644 --- a/config/locales/pa.yml +++ b/config/locales/pa.yml @@ -7,7 +7,13 @@ pa: hosted_on: "%{domain} ਉੱਤੇ ਹੋਸਟ ਕੀਤਾ ਮਸਟਾਡੋਨ" title: ਇਸ ਬਾਰੇ accounts: + followers: + one: ਫ਼ਾਲੋਅਰ + other: ਫ਼ਾਲੋਅਰ following: ਫ਼ਾਲੋ ਕੀਤੇ ਜਾ ਰਹੇ + posts: + one: ਪੋਸਟ + other: ਪੋਸਟਾਂ posts_tab_heading: ਪੋਸਟਾਂ admin: account_moderation_notes: @@ -126,6 +132,9 @@ pa: thread: ਗੱਲਾਂਬਾਤਾਂ index: delete: ਹਟਾਓ + statuses: + one: "%{count} ਪੋਸਟ" + other: "%{count} ਪੋਸਟ" generic: all: ਸਭ copy: ਕਾਪੀ ਕਰੋ diff --git a/config/locales/simple_form.es-MX.yml b/config/locales/simple_form.es-MX.yml index 02ecd4ebe8..e8080b2a76 100644 --- a/config/locales/simple_form.es-MX.yml +++ b/config/locales/simple_form.es-MX.yml @@ -61,7 +61,7 @@ es-MX: setting_display_media_default: Ocultar contenido multimedia marcado como sensible setting_display_media_hide_all: Siempre ocultar todo el contenido multimedia setting_display_media_show_all: Mostrar siempre contenido multimedia marcado como sensible - setting_emoji_style: Cómo se mostrarán los emojis. "Auto" intentará usar emojis nativos, cambiando a Twemoji en navegadores antiguos. + setting_emoji_style: Cómo se muestran los emojis. «Automático» intentará usar emojis nativos, pero vuelve a Twemoji para los navegadores antiguos. setting_system_scrollbars_ui: Solo se aplica a los navegadores de escritorio basados en Safari y Chrome setting_use_blurhash: Los degradados se basan en los colores de los elementos visuales ocultos, pero ocultan cualquier detalle setting_use_pending_items: Ocultar las publicaciones de la línea de tiempo tras un clic en lugar de desplazar automáticamente el feed @@ -151,8 +151,8 @@ es-MX: user: chosen_languages: Cuando se marca, solo se mostrarán las publicaciones en los idiomas seleccionados en las líneas de tiempo públicas date_of_birth: - one: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No guardaremos esta información. - other: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No guardaremos esta información. + one: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No almacenaremos esta información. + other: Tenemos que asegurarnos de que tienes al menos %{count} para usar %{domain}. No almacenaremos esta información. role: El rol controla qué permisos tiene el usuario. user_role: color: Color que se usará para el rol en toda la interfaz de usuario, como RGB en formato hexadecimal diff --git a/config/vite/plugin-assets-manifest.ts b/config/vite/plugin-assets-manifest.ts new file mode 100644 index 0000000000..b74b505398 --- /dev/null +++ b/config/vite/plugin-assets-manifest.ts @@ -0,0 +1,87 @@ +// Heavily inspired by https://github.com/ElMassimo/vite_ruby + +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import glob from 'fast-glob'; +import type { Plugin } from 'vite'; + +interface AssetManifestChunk { + file: string; + integrity: string; +} + +const ALGORITHM = 'sha384'; + +export function MastodonAssetsManifest(): Plugin { + let manifest: string | boolean = true; + let jsRoot = ''; + + return { + name: 'mastodon-assets-manifest', + applyToEnvironment(environment) { + return !!environment.config.build.manifest; + }, + configResolved(resolvedConfig) { + manifest = resolvedConfig.build.manifest; + jsRoot = resolvedConfig.root; + }, + async generateBundle() { + // Glob all assets and return an array of absolute paths. + const assetPaths = await glob( + ['flavours/*/{fonts,icons,images}/**/*', '{fonts,icons,images}/**/*'], + { + cwd: jsRoot, + absolute: true, + }, + ); + + const assetManifest: Record = {}; + const excludeExts = ['', '.md']; + for (const file of assetPaths) { + // Exclude files like markdown or README files with no extension. + const ext = path.extname(file); + if (excludeExts.includes(ext)) { + continue; + } + + // Read the file and emit it as an asset. + const contents = await fs.readFile(file); + const ref = this.emitFile({ + name: path.basename(file), + type: 'asset', + source: contents, + }); + const hashedFilename = this.getFileName(ref); + + // With the emitted file information, hash the contents and store in manifest. + const name = path.relative(jsRoot, file); + const hash = createHash(ALGORITHM) + .update(contents) + .digest() + .toString('base64'); + assetManifest[name] = { + file: hashedFilename, + integrity: `${ALGORITHM}-${hash}`, + }; + } + + if (Object.keys(assetManifest).length === 0) { + console.warn('Asset manifest is empty'); + return; + } + + // Get manifest location and emit the manifest. + const manifestDir = + typeof manifest === 'string' ? path.dirname(manifest) : '.vite'; + const fileName = `${manifestDir}/manifest-assets.json`; + + this.emitFile({ + fileName, + type: 'asset', + source: JSON.stringify(assetManifest, null, 2), + }); + }, + }; +} diff --git a/package.json b/package.json index 7f1e0795fc..9089397403 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,6 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", - "react-hotkeys": "^1.1.4", "react-immutable-proptypes": "^2.2.0", "react-immutable-pure-component": "^2.2.2", "react-intl": "^7.1.10", @@ -165,12 +164,12 @@ "@vitest/browser": "^3.2.1", "@vitest/coverage-v8": "^3.2.0", "@vitest/ui": "^3.2.1", - "chromatic": "^12.1.0", + "chromatic": "^13.0.0", "eslint": "^9.23.0", "eslint-import-resolver-typescript": "^4.2.5", "eslint-plugin-formatjs": "^5.3.1", "eslint-plugin-import": "~2.31.0", - "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-jsdoc": "^51.0.0", "eslint-plugin-jsx-a11y": "~6.10.2", "eslint-plugin-promise": "~7.2.1", "eslint-plugin-react": "^7.37.4", @@ -181,7 +180,7 @@ "lint-staged": "^16.0.0", "msw": "^2.10.2", "msw-storybook-addon": "^2.0.5", - "playwright": "^1.52.0", + "playwright": "^1.54.1", "prettier": "^3.3.3", "react-test-renderer": "^18.2.0", "storybook": "^9.0.4", diff --git a/vite.config.mts b/vite.config.mts index 6abaeb47bd..460c851dcd 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -4,7 +4,6 @@ import { readdir } from 'node:fs/promises'; import { optimizeLodashImports } from '@optimize-lodash/rollup-plugin'; import legacy from '@vitejs/plugin-legacy'; import react from '@vitejs/plugin-react'; -import glob from 'fast-glob'; import postcssPresetEnv from 'postcss-preset-env'; import Compress from 'rollup-plugin-gzip'; import { visualizer } from 'rollup-plugin-visualizer'; @@ -22,8 +21,9 @@ import tsconfigPaths from 'vite-tsconfig-paths'; import { MastodonServiceWorkerLocales } from './config/vite/plugin-sw-locales'; import { MastodonEmojiCompressed } from './config/vite/plugin-emoji-compressed'; -import { GlitchThemes } from './config/vite/plugin-glitch-themes'; +import { GlitchThemes as MastodonThemes } from './config/vite/plugin-glitch-themes'; import { MastodonNameLookup } from './config/vite/plugin-name-lookup'; +import { MastodonAssetsManifest } from './config/vite/plugin-assets-manifest'; const jsRoot = path.resolve(__dirname, 'app/javascript'); @@ -119,7 +119,8 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => { plugins: ['formatjs', 'transform-react-remove-prop-types'], }, }), - GlitchThemes(), + MastodonThemes(), + MastodonAssetsManifest(), viteStaticCopy({ targets: [ { @@ -144,7 +145,7 @@ export const config: UserConfigFnPromise = async ({ mode, command }) => { isProdBuild && (Compress() as PluginOption), command === 'build' && manifestSRI({ - manifestPaths: ['.vite/manifest.json', '.vite/manifest-assets.json'], + manifestPaths: ['.vite/manifest.json'], }), VitePWA({ srcDir: path.resolve(jsRoot, 'mastodon/service_worker'), @@ -211,21 +212,6 @@ async function findEntrypoints() { } } - // Lastly other assets - const assetEntrypoints = await glob('{fonts,icons,images}/**/*', { - cwd: jsRoot, - absolute: true, - }); - const excludeExts = ['', '.md']; - for (const file of assetEntrypoints) { - const ext = path.extname(file); - if (excludeExts.includes(ext)) { - continue; - } - const name = path.basename(file); - entrypoints[name] = path.resolve(jsRoot, file); - } - return entrypoints; } diff --git a/yarn.lock b/yarn.lock index 0fe7a8a7c6..ba5f4c1789 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1932,14 +1932,16 @@ __metadata: languageName: node linkType: hard -"@es-joy/jsdoccomment@npm:~0.49.0": - version: 0.49.0 - resolution: "@es-joy/jsdoccomment@npm:0.49.0" +"@es-joy/jsdoccomment@npm:~0.52.0": + version: 0.52.0 + resolution: "@es-joy/jsdoccomment@npm:0.52.0" dependencies: + "@types/estree": "npm:^1.0.8" + "@typescript-eslint/types": "npm:^8.34.1" comment-parser: "npm:1.4.1" esquery: "npm:^1.6.0" jsdoc-type-pratt-parser: "npm:~4.1.0" - checksum: 10c0/16717507d557d37e7b59456fedeefbe0a3bc93aa2d9c043d5db91e24e076509b6fcb10ee6fd1dafcb0c5bbe50ae329b45de5b83541cb5994a98c9e862a45641e + checksum: 10c0/4def78060ef58859f31757b9d30c4939fc33e7d9ee85637a7f568c1d209c33aa0abd2cf5a3a4f3662ec5b12b85ecff2f2035d809dc93b9382a31a6dfb200d83c languageName: node linkType: hard @@ -2660,7 +2662,7 @@ __metadata: babel-plugin-formatjs: "npm:^10.5.37" babel-plugin-transform-react-remove-prop-types: "npm:^0.4.24" blurhash: "npm:^2.0.5" - chromatic: "npm:^12.1.0" + chromatic: "npm:^13.0.0" classnames: "npm:^2.3.2" cocoon-js-vanilla: "npm:^1.5.1" color-blend: "npm:^4.0.0" @@ -2675,7 +2677,7 @@ __metadata: eslint-import-resolver-typescript: "npm:^4.2.5" eslint-plugin-formatjs: "npm:^5.3.1" eslint-plugin-import: "npm:~2.31.0" - eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-jsdoc: "npm:^51.0.0" eslint-plugin-jsx-a11y: "npm:~6.10.2" eslint-plugin-promise: "npm:~7.2.1" eslint-plugin-react: "npm:^7.37.4" @@ -2700,7 +2702,7 @@ __metadata: msw: "npm:^2.10.2" msw-storybook-addon: "npm:^2.0.5" path-complete-extname: "npm:^1.0.0" - playwright: "npm:^1.52.0" + playwright: "npm:^1.54.1" postcss-preset-env: "npm:^10.1.5" prettier: "npm:^3.3.3" prop-types: "npm:^15.8.1" @@ -2708,7 +2710,6 @@ __metadata: react: "npm:^18.2.0" react-dom: "npm:^18.2.0" react-helmet: "npm:^6.1.0" - react-hotkeys: "npm:^1.1.4" react-immutable-proptypes: "npm:^2.2.0" react-immutable-pure-component: "npm:^2.2.2" react-intl: "npm:^7.1.10" @@ -3091,13 +3092,6 @@ __metadata: languageName: node linkType: hard -"@pkgr/core@npm:^0.1.0": - version: 0.1.1 - resolution: "@pkgr/core@npm:0.1.1" - checksum: 10c0/3f7536bc7f57320ab2cf96f8973664bef624710c403357429fbf680a5c3b4843c1dbd389bb43daa6b1f6f1f007bb082f5abcb76bb2b5dc9f421647743b71d3d8 - languageName: node - linkType: hard - "@polka/url@npm:^1.0.0-next.24": version: 1.0.0-next.29 resolution: "@polka/url@npm:1.0.0-next.29" @@ -3988,10 +3982,10 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:*, @types/estree@npm:1.0.7, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6": - version: 1.0.7 - resolution: "@types/estree@npm:1.0.7" - checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c +"@types/estree@npm:*, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 languageName: node linkType: hard @@ -4002,6 +3996,13 @@ __metadata: languageName: node linkType: hard +"@types/estree@npm:1.0.7": + version: 1.0.7 + resolution: "@types/estree@npm:1.0.7" + checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c + languageName: node + linkType: hard + "@types/express-serve-static-core@npm:^4.17.33": version: 4.17.41 resolution: "@types/express-serve-static-core@npm:4.17.41" @@ -4504,13 +4505,20 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.33.0, @typescript-eslint/types@npm:^8.33.0": +"@typescript-eslint/types@npm:8.33.0": version: 8.33.0 resolution: "@typescript-eslint/types@npm:8.33.0" checksum: 10c0/348b64eb408719d7711a433fc9716e0c2aab8b3f3676f5a1cc2e00269044132282cf655deb6d0dd9817544116909513de3b709005352d186949d1014fad1a3cb languageName: node linkType: hard +"@typescript-eslint/types@npm:^8.33.0, @typescript-eslint/types@npm:^8.34.1": + version: 8.36.0 + resolution: "@typescript-eslint/types@npm:8.36.0" + checksum: 10c0/cacb941a0caad6ab556c416051b97ec33b364b7c8e0703e2729ae43f12daf02b42eef12011705329107752e3f1685ca82cfffe181d637f85907293cb634bee31 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:8.29.1": version: 8.29.1 resolution: "@typescript-eslint/typescript-estree@npm:8.29.1" @@ -4999,12 +5007,12 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.14.0, acorn@npm:^8.8.2": - version: 8.14.1 - resolution: "acorn@npm:8.14.1" +"acorn@npm:^8.14.0, acorn@npm:^8.15.0, acorn@npm:^8.8.2": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" bin: acorn: bin/acorn - checksum: 10c0/dbd36c1ed1d2fa3550140000371fcf721578095b18777b85a79df231ca093b08edc6858d75d6e48c73e431c174dcf9214edbd7e6fa5911b93bd8abfa54e47123 + checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec languageName: node linkType: hard @@ -5389,13 +5397,13 @@ __metadata: linkType: hard "axios@npm:^1.4.0": - version: 1.9.0 - resolution: "axios@npm:1.9.0" + version: 1.10.0 + resolution: "axios@npm:1.10.0" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10c0/9371a56886c2e43e4ff5647b5c2c3c046ed0a3d13482ef1d0135b994a628c41fbad459796f101c655e62f0c161d03883454474d2e435b2e021b1924d9f24994c + checksum: 10c0/2239cb269cc789eac22f5d1aabd58e1a83f8f364c92c2caa97b6f5cbb4ab2903d2e557d9dc670b5813e9bcdebfb149e783fb8ab3e45098635cd2f559b06bd5d8 languageName: node linkType: hard @@ -5825,9 +5833,9 @@ __metadata: languageName: node linkType: hard -"chromatic@npm:^12.1.0": - version: 12.1.0 - resolution: "chromatic@npm:12.1.0" +"chromatic@npm:^13.0.0": + version: 13.0.0 + resolution: "chromatic@npm:13.0.0" peerDependencies: "@chromatic-com/cypress": ^0.*.* || ^1.0.0 "@chromatic-com/playwright": ^0.*.* || ^1.0.0 @@ -5840,7 +5848,7 @@ __metadata: chroma: dist/bin.js chromatic: dist/bin.js chromatic-cli: dist/bin.js - checksum: 10c0/4acb70a4a84605f1963a823beed4f3062ec91e373104500f4295af2298b8d0b49f864d06ca81bc9389e44cae3a284332aac07c6cbfc123aa6457f3b52a4c4b78 + checksum: 10c0/30c697eb84d5b3b8cdab989df0e4fed0bf51f4bfefb616873f68fc00337978b9b38b84e52af22861769176181bd98525d467baeb22daa712a0f7a58bd61bf336 languageName: node linkType: hard @@ -6790,7 +6798,7 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.5.3, es-module-lexer@npm:^1.7.0": +"es-module-lexer@npm:^1.7.0": version: 1.7.0 resolution: "es-module-lexer@npm:1.7.0" checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b @@ -7050,24 +7058,23 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jsdoc@npm:^50.6.9": - version: 50.6.9 - resolution: "eslint-plugin-jsdoc@npm:50.6.9" +"eslint-plugin-jsdoc@npm:^51.0.0": + version: 51.3.4 + resolution: "eslint-plugin-jsdoc@npm:51.3.4" dependencies: - "@es-joy/jsdoccomment": "npm:~0.49.0" + "@es-joy/jsdoccomment": "npm:~0.52.0" are-docs-informative: "npm:^0.0.2" comment-parser: "npm:1.4.1" - debug: "npm:^4.3.6" + debug: "npm:^4.4.1" escape-string-regexp: "npm:^4.0.0" - espree: "npm:^10.1.0" + espree: "npm:^10.4.0" esquery: "npm:^1.6.0" - parse-imports: "npm:^2.1.1" - semver: "npm:^7.6.3" + parse-imports-exports: "npm:^0.2.4" + semver: "npm:^7.7.2" spdx-expression-parse: "npm:^4.0.0" - synckit: "npm:^0.9.1" peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - checksum: 10c0/cad199d262c2e889a3af4e402f6adc624e4273b3d5ca1940e7227b37d87af8090ca3444f7fff57f58dab9a827faed8722fc2f5d4daf31ec085eb00e9f5a338a7 + checksum: 10c0/59e5aa972bdd1bd4e2ca2796ed4455dff1069044abc028621e107aa4b0cbb62ce09554c8e7c2ff3a44a1cbd551e54b6970adc420ba3a89adc6236b94310a81ff languageName: node linkType: hard @@ -7173,10 +7180,10 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^4.2.0": - version: 4.2.0 - resolution: "eslint-visitor-keys@npm:4.2.0" - checksum: 10c0/2ed81c663b147ca6f578312919483eb040295bbab759e5a371953456c636c5b49a559883e2677112453728d66293c0a4c90ab11cab3428cf02a0236d2e738269 +"eslint-visitor-keys@npm:^4.2.0, eslint-visitor-keys@npm:^4.2.1": + version: 4.2.1 + resolution: "eslint-visitor-keys@npm:4.2.1" + checksum: 10c0/fcd43999199d6740db26c58dbe0c2594623e31ca307e616ac05153c9272f12f1364f5a0b1917a8e962268fdecc6f3622c1c2908b4fcc2e047a106fe6de69dc43 languageName: node linkType: hard @@ -7230,14 +7237,14 @@ __metadata: languageName: node linkType: hard -"espree@npm:^10.0.1, espree@npm:^10.1.0, espree@npm:^10.3.0": - version: 10.3.0 - resolution: "espree@npm:10.3.0" +"espree@npm:^10.0.1, espree@npm:^10.3.0, espree@npm:^10.4.0": + version: 10.4.0 + resolution: "espree@npm:10.4.0" dependencies: - acorn: "npm:^8.14.0" + acorn: "npm:^8.15.0" acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^4.2.0" - checksum: 10c0/272beeaca70d0a1a047d61baff64db04664a33d7cfb5d144f84bc8a5c6194c6c8ebe9cc594093ca53add88baa23e59b01e69e8a0160ab32eac570482e165c462 + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10c0/c63fe06131c26c8157b4083313cb02a9a54720a08e21543300e55288c40e06c3fc284bdecf108d3a1372c5934a0a88644c98714f38b6ae8ed272b40d9ea08d6b languageName: node linkType: hard @@ -9188,27 +9195,6 @@ __metadata: languageName: node linkType: hard -"lodash.isboolean@npm:^3.0.3": - version: 3.0.3 - resolution: "lodash.isboolean@npm:3.0.3" - checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7 - languageName: node - linkType: hard - -"lodash.isequal@npm:^4.5.0": - version: 4.5.0 - resolution: "lodash.isequal@npm:4.5.0" - checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f - languageName: node - linkType: hard - -"lodash.isobject@npm:^3.0.2": - version: 3.0.2 - resolution: "lodash.isobject@npm:3.0.2" - checksum: 10c0/da4c8480d98b16835b59380b2fbd43c54081acd9466febb788ba77c434384349e0bec162d1c4e89f613f21687b2b6d8384d8a112b80da00c78d28d9915a5cdde - languageName: node - linkType: hard - "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -9619,13 +9605,6 @@ __metadata: languageName: node linkType: hard -"mousetrap@npm:^1.5.2": - version: 1.6.5 - resolution: "mousetrap@npm:1.6.5" - checksum: 10c0/5c361bdbbff3966fd58d70f39b9fe1f8e32c78f3ce65989d83af7aad32a3a95313ce835a8dd8a55cb5de9eeb7c1f0c2b9048631a3073b5606241589e8fc0ba53 - languageName: node - linkType: hard - "mrmime@npm:^2.0.0": version: 2.0.1 resolution: "mrmime@npm:2.0.1" @@ -10048,13 +10027,12 @@ __metadata: languageName: node linkType: hard -"parse-imports@npm:^2.1.1": - version: 2.1.1 - resolution: "parse-imports@npm:2.1.1" +"parse-imports-exports@npm:^0.2.4": + version: 0.2.4 + resolution: "parse-imports-exports@npm:0.2.4" dependencies: - es-module-lexer: "npm:^1.5.3" - slashes: "npm:^3.0.12" - checksum: 10c0/c9bb0b4e1823f84f034d2d7bd2b37415b1715a5c963fda14968c706186b48b02c10e97d04bce042b9dcd679b42f29c391ea120799ddf581c7f54786edd99e3a9 + parse-statements: "npm:1.0.11" + checksum: 10c0/51b729037208abdf65c4a1f8e9ed06f4e7ccd907c17c668a64db54b37d95bb9e92081f8b16e4133e14102af3cb4e89870975b6ad661b4d654e9ec8f4fb5c77d6 languageName: node linkType: hard @@ -10070,6 +10048,13 @@ __metadata: languageName: node linkType: hard +"parse-statements@npm:1.0.11": + version: 1.0.11 + resolution: "parse-statements@npm:1.0.11" + checksum: 10c0/48960e085019068a5f5242e875fd9d21ec87df2e291acf5ad4e4887b40eab6929a8c8d59542acb85a6497e870c5c6a24f5ab7f980ef5f907c14cc5f7984a93f3 + languageName: node + linkType: hard + "parse5@npm:^7.2.1": version: 7.2.1 resolution: "parse5@npm:7.2.1" @@ -10358,27 +10343,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.52.0": - version: 1.52.0 - resolution: "playwright-core@npm:1.52.0" +"playwright-core@npm:1.54.1": + version: 1.54.1 + resolution: "playwright-core@npm:1.54.1" bin: playwright-core: cli.js - checksum: 10c0/640945507e6ca2144e9f596b2a6ecac042c2fd3683ff99e6271e9a7b38f3602d415f282609d569456f66680aab8b3c5bb1b257d8fb63a7fc0ed648261110421f + checksum: 10c0/b821262b024d7753b1bfa71eb2bc99f2dda12a869d175b2e1bc6ac2764bd661baf36d9d42f45caf622854ad7e4a6077b9b57014c74bb5a78fe339c9edf1c9019 languageName: node linkType: hard -"playwright@npm:^1.52.0": - version: 1.52.0 - resolution: "playwright@npm:1.52.0" +"playwright@npm:^1.54.1": + version: 1.54.1 + resolution: "playwright@npm:1.54.1" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.52.0" + playwright-core: "npm:1.54.1" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10c0/2c6edf1e15e59bbaf77f3fa0fe0ac975793c17cff835d9c8b8bc6395a3b6f1c01898b3058ab37891b2e4d424bcc8f1b4844fe70d943e0143d239d7451408c579 + checksum: 10c0/c5fedae31a03a1f4c4846569aef3ffb98da23000a4d255abfc8c2ede15b43cc7cd87b80f6fa078666c030373de8103787cf77ef7653ae9458aabbbd4320c2599 languageName: node linkType: hard @@ -11131,22 +11116,6 @@ __metadata: languageName: node linkType: hard -"react-hotkeys@npm:^1.1.4": - version: 1.1.4 - resolution: "react-hotkeys@npm:1.1.4" - dependencies: - lodash.isboolean: "npm:^3.0.3" - lodash.isequal: "npm:^4.5.0" - lodash.isobject: "npm:^3.0.2" - mousetrap: "npm:^1.5.2" - prop-types: "npm:^15.6.0" - peerDependencies: - react: ">= 0.14.0" - react-dom: ">= 0.14.0" - checksum: 10c0/6bd566ea97e00058749d43d768ee843e5132f988571536e090b564d5dbaa71093695255514fc5b9fcf9fbd03fcb0603f6e135dcab6dcaaffe43dedbfe742a163 - languageName: node - linkType: hard - "react-immutable-proptypes@npm:^2.2.0": version: 2.2.0 resolution: "react-immutable-proptypes@npm:2.2.0" @@ -12092,7 +12061,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1": +"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.7.1, semver@npm:^7.7.2": version: 7.7.2 resolution: "semver@npm:7.7.2" bin: @@ -12297,13 +12266,6 @@ __metadata: languageName: node linkType: hard -"slashes@npm:^3.0.12": - version: 3.0.12 - resolution: "slashes@npm:3.0.12" - checksum: 10c0/71ca2a1fcd1ab6814b0fdb8cf9c33a3d54321deec2aa8d173510f0086880201446021a9b9e6a18561f7c472b69a2145977c6a8fb9c53a8ff7be31778f203d175 - languageName: node - linkType: hard - "slice-ansi@npm:^4.0.0": version: 4.0.0 resolution: "slice-ansi@npm:4.0.0" @@ -12991,16 +12953,6 @@ __metadata: languageName: node linkType: hard -"synckit@npm:^0.9.1": - version: 0.9.1 - resolution: "synckit@npm:0.9.1" - dependencies: - "@pkgr/core": "npm:^0.1.0" - tslib: "npm:^2.6.2" - checksum: 10c0/d8b89e1bf30ba3ffb469d8418c836ad9c0c062bf47028406b4d06548bc66af97155ea2303b96c93bf5c7c0f0d66153a6fbd6924c76521b434e6a9898982abc2e - languageName: node - linkType: hard - "systemjs@npm:^6.15.1": version: 6.15.1 resolution: "systemjs@npm:6.15.1" @@ -13341,7 +13293,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.4.0, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62