Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Message keyboard navigability #31549

Merged
merged 18 commits into from Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spicy-wombats-shout.md
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': minor
---

Introduces message navigability, allowing users to navigate on messages through keyboard
31 changes: 15 additions & 16 deletions apps/meteor/client/components/message/MessageHeader.tsx
Expand Up @@ -8,7 +8,7 @@ import {
MessageNameContainer,
} from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import type { KeyboardEvent, ReactElement } from 'react';
import React, { memo } from 'react';

import { getUserDisplayName } from '../../../lib/getUserDisplayName';
Expand Down Expand Up @@ -45,31 +45,30 @@ const MessageHeader = ({ message }: MessageHeaderProps): ReactElement => {

return (
<FuselageMessageHeader>
<MessageNameContainer>
<MessageNameContainer
tabIndex={0}
role='button'
aria-label={getUserDisplayName(user.name, user.username, showRealName)}
{...(user.username !== undefined &&
chat?.userCard && {
onClick: (e) => chat?.userCard.openUserCard(e, message.u.username),
onKeyDown: (e: KeyboardEvent<HTMLSpanElement>) => {
(e.code === 'Enter' || e.code === 'Space') && chat?.userCard.openUserCard(e, message.u.username);
},
style: { cursor: 'pointer' },
})}
>
<MessageName
{...(!showUsername && { 'data-qa-type': 'username' })}
title={!showUsername && !usernameAndRealNameAreSame ? `@${user.username}` : undefined}
data-username={user.username}
{...(user.username !== undefined &&
chat?.userCard && {
onClick: (e) => chat?.userCard.openUserCard(e, message.u.username),
style: { cursor: 'pointer' },
})}
>
{message.alias || getUserDisplayName(user.name, user.username, showRealName)}
</MessageName>
{showUsername && (
<>
{' '}
<MessageUsername
data-username={user.username}
data-qa-type='username'
{...(user.username !== undefined &&
chat?.userCard && {
onClick: (e) => chat?.userCard.openUserCard(e, message.u.username),
style: { cursor: 'pointer' },
})}
>
<MessageUsername data-username={user.username} data-qa-type='username'>
@{user.username}
</MessageUsername>
</>
Expand Down
@@ -1,11 +1,12 @@
import { useToolbar } from '@react-aria/toolbar';
import type { IMessage, IRoom, ISubscription, ITranslatedMessage } from '@rocket.chat/core-typings';
import { isThreadMessage, isRoomFederated, isVideoConfMessage } from '@rocket.chat/core-typings';
import { MessageToolbar as FuselageMessageToolbar, MessageToolbarItem } from '@rocket.chat/fuselage';
import { useFeaturePreview } from '@rocket.chat/ui-client';
import { useUser, useSettings, useTranslation, useMethod, useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ReactElement } from 'react';
import React, { memo, useMemo } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import React, { memo, useMemo, useRef } from 'react';

import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
Expand Down Expand Up @@ -45,19 +46,23 @@ type MessageToolbarProps = {
room: IRoom;
subscription?: ISubscription;
onChangeMenuVisibility: (visible: boolean) => void;
};
} & ComponentProps<typeof FuselageMessageToolbar>;

const MessageToolbar = ({
message,
messageContext,
room,
subscription,
onChangeMenuVisibility,
...props
}: MessageToolbarProps): ReactElement | null => {
const t = useTranslation();
const user = useUser() ?? undefined;
const settings = useSettings();

const toolbarRef = useRef(null);
const { toolbarProps } = useToolbar(props, toolbarRef);

const quickReactionsEnabled = useFeaturePreview('quickReactions');

const setReaction = useMethod('setReaction');
Expand Down Expand Up @@ -106,7 +111,7 @@ const MessageToolbar = ({
};

return (
<FuselageMessageToolbar>
<FuselageMessageToolbar ref={toolbarRef} {...toolbarProps} aria-label={t('Message_actions')} {...props}>
{quickReactionsEnabled &&
isReactionAllowed &&
quickReactions.slice(0, 3).map(({ emoji, image }) => {
Expand Down
11 changes: 7 additions & 4 deletions apps/meteor/client/components/message/variants/RoomMessage.tsx
Expand Up @@ -3,7 +3,7 @@ import { Message, MessageLeftContainer, MessageContainer, CheckBox } from '@rock
import { useToggle } from '@rocket.chat/fuselage-hooks';
import { MessageAvatar } from '@rocket.chat/ui-avatar';
import { useUserId } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import React, { useRef, memo } from 'react';

import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction';
Expand Down Expand Up @@ -33,7 +33,7 @@ type RoomMessageProps = {
context?: MessageActionContext;
ignoredUser?: boolean;
searchText?: string;
};
} & ComponentProps<typeof Message>;

const RoomMessage = ({
message,
Expand All @@ -45,6 +45,7 @@ const RoomMessage = ({
context,
ignoredUser,
searchText,
...props
}: RoomMessageProps): ReactElement => {
const uid = useUserId();
const editing = useIsMessageHighlight(message._id);
Expand All @@ -60,10 +61,13 @@ const RoomMessage = ({
useCountSelected();

useJumpToMessage(message._id, messageRef);

return (
<Message
ref={messageRef}
id={message._id}
role='listitem'
tabIndex={0}
onClick={selecting ? toggleSelected : undefined}
isSelected={selected}
isEditing={editing}
Expand All @@ -78,6 +82,7 @@ const RoomMessage = ({
data-own={message.u._id === uid}
data-qa-type='message'
aria-busy={message.temp}
{...props}
>
<MessageLeftContainer>
{!sequential && message.u.username && !selecting && showUserAvatar && (
Expand All @@ -95,10 +100,8 @@ const RoomMessage = ({
{selecting && <CheckBox checked={selected} onChange={toggleSelected} />}
{sequential && <StatusIndicators message={message} />}
</MessageLeftContainer>

<MessageContainer>
{!sequential && <MessageHeader message={message} />}

{ignored ? (
<IgnoredContent onShowMessageIgnored={toggleDisplayIgnoredMessage} />
) : (
Expand Down
Expand Up @@ -14,7 +14,7 @@ import {
import { UserAvatar } from '@rocket.chat/ui-avatar';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import React, { memo } from 'react';

import { MessageTypes } from '../../../../app/ui-utils/client';
Expand All @@ -37,9 +37,9 @@ import { useMessageListShowRealName, useMessageListShowUsername } from '../list/
type SystemMessageProps = {
message: IMessage;
showUserAvatar: boolean;
};
} & ComponentProps<typeof MessageSystem>;

const SystemMessage = ({ message, showUserAvatar }: SystemMessageProps): ReactElement => {
const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps): ReactElement => {
const t = useTranslation();
const formatTime = useFormatTime();
const formatDateAndTime = useFormatDateAndTime();
Expand All @@ -59,11 +59,14 @@ const SystemMessage = ({ message, showUserAvatar }: SystemMessageProps): ReactEl

return (
<MessageSystem
role='listitem'
tabIndex={0}
onClick={isSelecting ? toggleSelected : undefined}
isSelected={isSelected}
data-qa-selected={isSelected}
data-qa='system-message'
data-system-message-type={message.t}
{...props}
>
<MessageSystemLeftContainer>
{!isSelecting && showUserAvatar && <UserAvatar username={message.u.username} size='x18' />}
Expand Down
Expand Up @@ -39,6 +39,8 @@ const ThreadMessage = ({ message, sequential, unread, showUserAvatar }: ThreadMe

return (
<Message
role='listitem'
tabIndex={0}
id={message._id}
ref={messageRef}
isEditing={editing}
Expand Down
Expand Up @@ -14,7 +14,7 @@ import {
} from '@rocket.chat/fuselage';
import { MessageAvatar } from '@rocket.chat/ui-avatar';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import React, { memo } from 'react';

import { MessageTypes } from '../../../../app/ui-utils/client';
Expand All @@ -36,7 +36,7 @@ type ThreadMessagePreviewProps = {
message: IThreadMessage;
showUserAvatar: boolean;
sequential: boolean;
};
} & ComponentProps<typeof ThreadMessage>;

const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: ThreadMessagePreviewProps): ReactElement => {
const parentMessage = useParentMessage(message.tmid);
Expand All @@ -56,23 +56,30 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }:

const goToThread = useGoToThread();

const handleThreadClick = () => {
if (!isSelecting) {
if (!sequential) {
return parentMessage.isSuccess && goToThread({ rid: message.rid, tmid: message.tmid, msg: parentMessage.data?._id });
}

return goToThread({ rid: message.rid, tmid: message.tmid, msg: message._id });
}

return toggleSelected();
};

return (
<ThreadMessage
{...props}
onClick={isSelecting ? toggleSelected : undefined}
tabIndex={0}
onClick={handleThreadClick}
onKeyDown={(e) => e.code === 'Enter' && handleThreadClick()}
isSelected={isSelected}
data-qa-selected={isSelected}
role='link'
{...props}
>
{!sequential && (
<ThreadMessageRow
role='link'
onClick={
!isSelecting && parentMessage.isSuccess
? () => goToThread({ rid: message.rid, tmid: message.tmid, msg: parentMessage.data?._id })
: undefined
}
>
<ThreadMessageRow>
<ThreadMessageLeftContainer>
<ThreadMessageIconThread />
</ThreadMessageLeftContainer>
Expand Down Expand Up @@ -100,7 +107,7 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }:
</ThreadMessageContainer>
</ThreadMessageRow>
)}
<ThreadMessageRow onClick={!isSelecting ? () => goToThread({ rid: message.rid, tmid: message.tmid, msg: message._id }) : undefined}>
<ThreadMessageRow>
<ThreadMessageLeftContainer>
{!isSelecting && showUserAvatar && (
<MessageAvatar
Expand Down
Expand Up @@ -30,7 +30,6 @@ const EmojiElement = ({ emoji, image, onClick, small = false, ...props }: EmojiE

return (
<IconButton
{...props}
{...(small && { className: emojiSmallClass })}
small={small}
medium={!small}
Expand All @@ -40,6 +39,7 @@ const EmojiElement = ({ emoji, image, onClick, small = false, ...props }: EmojiE
data-emoji={emoji}
aria-label={emoji}
icon={emojiElement}
{...props}
/>
);
};
Expand Down
55 changes: 31 additions & 24 deletions apps/meteor/client/views/room/Room.tsx
@@ -1,6 +1,7 @@
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React, { createElement, lazy, memo, Suspense } from 'react';
import { FocusScope } from 'react-aria';
import { ErrorBoundary } from 'react-error-boundary';

import { ContextualbarSkeleton } from '../../components/Contextualbar';
Expand All @@ -25,30 +26,36 @@ const Room = (): ReactElement => {
return (
<ChatProvider>
<MessageHighlightProvider>
<RoomLayout
aria-label={t('Channel')}
data-qa-rc-room={room._id}
header={<Header room={room} />}
body={<RoomBody />}
aside={
(toolbox.tab?.tabComponent && (
<ErrorBoundary fallback={null}>
<SelectedMessagesProvider>
<Suspense fallback={<ContextualbarSkeleton />}>{createElement(toolbox.tab.tabComponent)}</Suspense>
</SelectedMessagesProvider>
</ErrorBoundary>
)) ||
(contextualBarView && (
<ErrorBoundary fallback={null}>
<SelectedMessagesProvider>
<Suspense fallback={<ContextualbarSkeleton />}>
<UiKitContextualBar key={contextualBarView.id} initialView={contextualBarView} />
</Suspense>
</SelectedMessagesProvider>
</ErrorBoundary>
))
}
/>
<FocusScope>
<RoomLayout
data-qa-rc-room={room._id}
aria-label={
room.t === 'd'
? t('Conversation_with__roomName__', { roomName: room.name })
: t('Channel__roomName__', { roomName: room.name })
}
header={<Header room={room} />}
body={<RoomBody />}
aside={
(toolbox.tab?.tabComponent && (
<ErrorBoundary fallback={null}>
<SelectedMessagesProvider>
<Suspense fallback={<ContextualbarSkeleton />}>{createElement(toolbox.tab.tabComponent)}</Suspense>
</SelectedMessagesProvider>
</ErrorBoundary>
)) ||
(contextualBarView && (
<ErrorBoundary fallback={null}>
<SelectedMessagesProvider>
<Suspense fallback={<ContextualbarSkeleton />}>
<UiKitContextualBar key={contextualBarView.id} initialView={contextualBarView} />
</Suspense>
</SelectedMessagesProvider>
</ErrorBoundary>
))
}
/>
</FocusScope>
</MessageHighlightProvider>
</ChatProvider>
);
Expand Down