From af06d74574745deb738e6f526f4d74c31760102d Mon Sep 17 00:00:00 2001 From: Claire <claire.github-309c@sitedethib.com> Date: Tue, 23 Jul 2024 08:20:17 +0200 Subject: [PATCH] Fix keyboard shortcuts and navigation in grouped notifications (#31076) --- app/javascript/mastodon/actions/accounts.js | 12 +++ app/javascript/mastodon/actions/compose.js | 18 ++++ app/javascript/mastodon/actions/statuses.js | 14 +++ app/javascript/mastodon/components/status.jsx | 15 +-- .../components/notification_group.tsx | 21 ++++- .../notification_group_with_status.tsx | 80 ++++++++++------ .../components/notification_with_status.tsx | 94 +++++++++++++------ 7 files changed, 188 insertions(+), 66 deletions(-) diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index cea915e5f..914423519 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -1,3 +1,5 @@ +import { browserHistory } from 'mastodon/components/router'; + import api, { getLinks } from '../api'; import { @@ -676,3 +678,13 @@ export const updateAccount = ({ displayName, note, avatar, header, discoverable, dispatch(importFetchedAccount(response.data)); }); }; + +export const navigateToProfile = (accountId) => { + return (_dispatch, getState) => { + const acct = getState().accounts.getIn([accountId, 'acct']); + + if (acct) { + browserHistory.push(`/@${acct}`); + } + }; +}; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 4f2ed450d..aa1c6de20 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -122,6 +122,18 @@ export function replyCompose(status) { }; } +export function replyComposeById(statusId) { + return (dispatch, getState) => { + const state = getState(); + const status = state.statuses.get(statusId); + + if (status) { + const account = state.accounts.get(status.get('account')); + dispatch(replyCompose(status.set('account', account))); + } + }; +} + export function cancelReplyCompose() { return { type: COMPOSE_REPLY_CANCEL, @@ -154,6 +166,12 @@ export function mentionCompose(account) { }; } +export function mentionComposeById(accountId) { + return (dispatch, getState) => { + dispatch(mentionCompose(getState().accounts.get(accountId))); + }; +} + export function directCompose(account) { return (dispatch, getState) => { dispatch({ diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 26abaf1bc..340cee802 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -1,3 +1,5 @@ +import { browserHistory } from 'mastodon/components/router'; + import api from '../api'; import { ensureComposeIsVisible, setComposeToStatus } from './compose'; @@ -363,3 +365,15 @@ export const undoStatusTranslation = (id, pollId) => ({ id, pollId, }); + +export const navigateToStatus = (statusId) => { + return (_dispatch, getState) => { + const state = getState(); + const accountId = state.statuses.getIn([statusId, 'account']); + const acct = state.accounts.getIn([accountId, 'acct']); + + if (acct) { + browserHistory.push(`/@${acct}/${statusId}`); + } + }; +}; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index fe5f38889..6e3792d7d 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -119,6 +119,7 @@ class Status extends ImmutablePureComponent { skipPrepend: PropTypes.bool, avatarSize: PropTypes.number, deployPictureInPicture: PropTypes.func, + unfocusable: PropTypes.bool, pictureInPicture: ImmutablePropTypes.contains({ inUse: PropTypes.bool, available: PropTypes.bool, @@ -355,7 +356,7 @@ class Status extends ImmutablePureComponent { }; render () { - const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props; + const { intl, hidden, featured, unfocusable, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props; let { status, account, ...other } = this.props; @@ -381,8 +382,8 @@ class Status extends ImmutablePureComponent { if (hidden) { return ( - <HotKeys handlers={handlers}> - <div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={0}> + <HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}> + <div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}> <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span> <span>{status.get('content')}</span> </div> @@ -402,8 +403,8 @@ class Status extends ImmutablePureComponent { }; return ( - <HotKeys handlers={minHandlers}> - <div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}> + <HotKeys handlers={minHandlers} tabIndex={unfocusable ? null : -1}> + <div className='status__wrapper status__wrapper--filtered focusable' tabIndex={unfocusable ? null : 0} ref={this.handleRef}> <FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}. {' '} <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}> @@ -550,8 +551,8 @@ class Status extends ImmutablePureComponent { const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0; return ( - <HotKeys handlers={handlers}> - <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}> + <HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}> + <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}> {!skipPrepend && prepend} <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}> 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 1cfb235b7..36f033261 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx @@ -2,8 +2,10 @@ import { useMemo } from 'react'; import { HotKeys } from 'react-hotkeys'; +import { navigateToProfile } from 'mastodon/actions/accounts'; +import { mentionComposeById } from 'mastodon/actions/compose'; import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group'; -import { useAppSelector } from 'mastodon/store'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { NotificationAdminReport } from './notification_admin_report'; import { NotificationAdminSignUp } from './notification_admin_sign_up'; @@ -30,6 +32,13 @@ export const NotificationGroup: React.FC<{ ), ); + const dispatch = useAppDispatch(); + + const accountId = + notificationGroup?.type === 'gap' + ? undefined + : notificationGroup?.sampleAccountIds[0]; + const handlers = useMemo( () => ({ moveUp: () => { @@ -39,8 +48,16 @@ export const NotificationGroup: React.FC<{ moveDown: () => { onMoveDown(notificationGroupId); }, + + openProfile: () => { + if (accountId) dispatch(navigateToProfile(accountId)); + }, + + mention: () => { + if (accountId) dispatch(mentionComposeById(accountId)); + }, }), - [notificationGroupId, onMoveUp, onMoveDown], + [dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown], ); if (!notificationGroup || notificationGroup.type === 'gap') return null; 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 23004f7ee..2af73c836 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 @@ -2,9 +2,14 @@ import { useMemo } from 'react'; import classNames from 'classnames'; +import { HotKeys } from 'react-hotkeys'; + +import { replyComposeById } from 'mastodon/actions/compose'; +import { navigateToStatus } from 'mastodon/actions/statuses'; import type { IconProp } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; +import { useAppDispatch } from 'mastodon/store'; import { AvatarGroup } from './avatar_group'; import { EmbeddedStatus } from './embedded_status'; @@ -39,6 +44,8 @@ export const NotificationGroupWithStatus: React.FC<{ type, unread, }) => { + const dispatch = useAppDispatch(); + const label = useMemo( () => labelRenderer({ @@ -53,39 +60,54 @@ export const NotificationGroupWithStatus: React.FC<{ [labelRenderer, accountIds, count, labelSeeMoreHref], ); + const handlers = useMemo( + () => ({ + open: () => { + dispatch(navigateToStatus(statusId)); + }, + + reply: () => { + dispatch(replyComposeById(statusId)); + }, + }), + [dispatch, statusId], + ); + return ( - <div - role='button' - className={classNames( - `notification-group focusable notification-group--${type}`, - { 'notification-group--unread': unread }, - )} - tabIndex={0} - > - <div className='notification-group__icon'> - <Icon icon={icon} id={iconId} /> - </div> - - <div className='notification-group__main'> - <div className='notification-group__main__header'> - <div className='notification-group__main__header__wrapper'> - <AvatarGroup accountIds={accountIds} /> - - {actions} - </div> - - <div className='notification-group__main__header__label'> - {label} - {timestamp && <RelativeTimestamp timestamp={timestamp} />} - </div> + <HotKeys handlers={handlers}> + <div + role='button' + className={classNames( + `notification-group focusable notification-group--${type}`, + { 'notification-group--unread': unread }, + )} + tabIndex={0} + > + <div className='notification-group__icon'> + <Icon icon={icon} id={iconId} /> </div> - {statusId && ( - <div className='notification-group__main__status'> - <EmbeddedStatus statusId={statusId} /> + <div className='notification-group__main'> + <div className='notification-group__main__header'> + <div className='notification-group__main__header__wrapper'> + <AvatarGroup accountIds={accountIds} /> + + {actions} + </div> + + <div className='notification-group__main__header__label'> + {label} + {timestamp && <RelativeTimestamp timestamp={timestamp} />} + </div> </div> - )} + + {statusId && ( + <div className='notification-group__main__status'> + <EmbeddedStatus statusId={statusId} /> + </div> + )} + </div> </div> - </div> + </HotKeys> ); }; 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 27de76b48..c7dd9f6be 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,10 +2,18 @@ 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 type { IconProp } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon'; import Status from 'mastodon/containers/status_container'; -import { useAppSelector } from 'mastodon/store'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { NamesList } from './names_list'; import type { LabelRenderer } from './notification_group_with_status'; @@ -29,6 +37,8 @@ export const NotificationWithStatus: React.FC<{ type, unread, }) => { + const dispatch = useAppDispatch(); + const label = useMemo( () => labelRenderer({ @@ -41,33 +51,61 @@ export const NotificationWithStatus: React.FC<{ (state) => state.statuses.getIn([statusId, 'visibility']) === 'direct', ); - return ( - <div - role='button' - className={classNames( - `notification-ungrouped focusable notification-ungrouped--${type}`, - { - 'notification-ungrouped--unread': unread, - 'notification-ungrouped--direct': isPrivateMention, - }, - )} - tabIndex={0} - > - <div className='notification-ungrouped__header'> - <div className='notification-ungrouped__header__icon'> - <Icon icon={icon} id={iconId} /> - </div> - {label} - </div> + const handlers = useMemo( + () => ({ + open: () => { + dispatch(navigateToStatus(statusId)); + }, - <Status - // @ts-expect-error -- <Status> is not yet typed - id={statusId} - contextType='notifications' - withDismiss - skipPrepend - avatarSize={40} - /> - </div> + reply: () => { + dispatch(replyComposeById(statusId)); + }, + + boost: () => { + dispatch(toggleReblog(statusId)); + }, + + favourite: () => { + dispatch(toggleFavourite(statusId)); + }, + + toggleHidden: () => { + dispatch(toggleStatusSpoilers(statusId)); + }, + }), + [dispatch, statusId], + ); + + return ( + <HotKeys handlers={handlers}> + <div + role='button' + className={classNames( + `notification-ungrouped focusable notification-ungrouped--${type}`, + { + 'notification-ungrouped--unread': unread, + 'notification-ungrouped--direct': isPrivateMention, + }, + )} + tabIndex={0} + > + <div className='notification-ungrouped__header'> + <div className='notification-ungrouped__header__icon'> + <Icon icon={icon} id={iconId} /> + </div> + {label} + </div> + + <Status + // @ts-expect-error -- <Status> is not yet typed + id={statusId} + contextType='notifications' + withDismiss + skipPrepend + avatarSize={40} + unfocusable + /> + </div> + </HotKeys> ); };