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>
   );
 };