Fix keyboard shortcuts and navigation in grouped notifications (#31076)
This commit is contained in:
		@@ -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}`);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -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({
 | 
			
		||||
 
 | 
			
		||||
@@ -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}`);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -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')}>
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user