Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions src/components/Message/MessageText.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import clsx from 'clsx';
import React, { useMemo } from 'react';
import React, { useId, useMemo } from 'react';
import { messageHasAttachments, messageTextHasEmojisOnly } from './utils';

import type { MessageContextValue } from '../../context';
import { useMessageContext, useTranslationContext } from '../../context';
import { VisuallyHidden } from '../VisuallyHidden';
import { renderText as defaultRenderText } from './renderText';

import type { LocalMessage } from 'stream-chat';
Expand Down Expand Up @@ -37,9 +38,11 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => {

const renderText = propsRenderText ?? contextRenderText ?? defaultRenderText;

const { userLanguage } = useTranslationContext('MessageText');
const { t, userLanguage } = useTranslationContext('MessageText');
const message = propMessage || contextMessage;
const hasAttachment = messageHasAttachments(message);
const messageContextId = useId();
const messageTextId = useId();

const messageTextToRender =
translationView === 'original'
Expand All @@ -56,6 +59,13 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => {
const hasMentionedUsers = Boolean(message.mentioned_users?.length);
const isMentionsInteractionEnabled =
hasMentionedUsers && typeof onMentionsClickMessage === 'function';
const senderName = message.user?.name;
const messageContext = senderName
? t('aria/Message from {{ user }},', { user: senderName })
: t('aria/Message,');
// `aria-labelledby` accepts a space-separated list of element ids. We point to the
// hidden message context and the rendered message text so screen readers announce both.
const messageLabelledBy = `${messageContextId} ${messageTextId}`;

const handleMentionsKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isMentionsInteractionEnabled || (event.key !== 'Enter' && event.key !== ' ')) {
Expand All @@ -68,9 +78,27 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => {

if (!messageTextToRender) return null;

/**
* The component has two mutually exclusive focus models. The reason is this bit of behavior:
*
* if mentions are not interactive:
* - the whole message text block is just a readable focus stop
* - outer wrapper gets tabIndex={0}
* - inner wrapper is not focusable
* if mentions are interactive:
* - keyboard interaction needs to land on the inner element, because that’s where onClick, onKeyDown, and mention hover/click behavior live
* - inner wrapper gets tabIndex={0}
* - outer wrapper must stop being focusable, otherwise you create an extra dead focus stop before the actual interactive target
*/
return (
<div className={wrapperClass} tabIndex={isMentionsInteractionEnabled ? undefined : 0}>
<div
aria-labelledby={isMentionsInteractionEnabled ? undefined : messageLabelledBy}
className={wrapperClass}
tabIndex={isMentionsInteractionEnabled ? undefined : 0}
>
<VisuallyHidden id={messageContextId}>{messageContext}</VisuallyHidden>
<div
aria-labelledby={isMentionsInteractionEnabled ? messageLabelledBy : undefined}
className={clsx(innerClass, {
[` str-chat__message-text-inner--is-emoji`]:
messageTextHasEmojisOnly(message) && !message.quoted_message,
Expand All @@ -83,9 +111,9 @@ const UnMemoizedMessageTextComponent = (props: MessageTextProps) => {
tabIndex={isMentionsInteractionEnabled ? 0 : undefined}
>
{unsafeHTML && message.html ? (
<div dangerouslySetInnerHTML={{ __html: message.html }} />
<div dangerouslySetInnerHTML={{ __html: message.html }} id={messageTextId} />
) : (
<div>{messageText}</div>
<div id={messageTextId}>{messageText}</div>
)}
</div>
</div>
Expand Down
45 changes: 44 additions & 1 deletion src/components/Message/__tests__/MessageText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ const defaultProps = {
threadList: false,
};

const translate = (key: string, options?: Record<string, string>) =>
key.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, token: string) => options?.[token] ?? '');

function generateAliceMessage(messageOptions) {
return generateMessage({
user: alice,
Expand Down Expand Up @@ -100,7 +103,8 @@ async function renderMessageText({
>
<TranslationProvider
value={mockTranslationContextValue({
t: ((key: string) => key) as TranslationContextValue['t'],
t: ((key: string, options?: Record<string, string>) =>
translate(key, options)) as TranslationContextValue['t'],
tDateTimeParser:
customDateTimeParser as TranslationContextValue['tDateTimeParser'],
userLanguage: 'en',
Expand Down Expand Up @@ -254,6 +258,45 @@ describe('<MessageText />', () => {
expect(results).toHaveNoViolations();
});

it('should expose sender context on the focusable message wrapper', async () => {
const text = 'Hello, world!';
const message = generateAliceMessage({ text });
const { getByTestId } = await renderMessageText({
customProps: { message },
});

const focusableWrapper = getByTestId(messageTextTestId).parentElement;

expect(focusableWrapper).toHaveAccessibleName(`aria/Message from alice, ${text}`);
});

it('should expose sender context on the mention-interactive text wrapper', async () => {
const text = 'Hello @bob';
const message = generateAliceMessage({ mentioned_users: [bob], text });
const { getByTestId } = await renderMessageText({
customProps: { message },
});

expect(getByTestId(messageTextTestId)).toHaveAccessibleName(
`aria/Message from alice, ${text}`,
);
});

it('should not expose message user id in the accessible name fallback', async () => {
const text = 'Hello, world!';
const message = generateMessage({
text,
user: generateUser({ id: 'alice-id', name: undefined }),
});
const { getByTestId } = await renderMessageText({
customProps: { message },
});

const focusableWrapper = getByTestId(messageTextTestId).parentElement;

expect(focusableWrapper).toHaveAccessibleName(`aria/Message, ${text}`);
});

it('should inform that message was not sent when message is has type "error"', async () => {
const message = generateAliceMessage({ type: 'error' });
const { container } = await renderMessageText({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,23 @@ exports[`<MessageText /> > should render with a custom inner class when one is s
class="str-chat__message-bubble"
>
<div
aria-labelledby=":r1c: :r1d:"
class="str-chat__message-text"
tabindex="0"
>
<span
id=":r1c:"
style="border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; white-space: nowrap; width: 1px;"
>
aria/Message,
</span>
<div
class=""
data-testid="message-text-inner-wrapper"
>
<div>
<div
id=":r1d:"
>
<p>
hi mate
</p>
Expand Down Expand Up @@ -80,14 +89,23 @@ exports[`<MessageText /> > should render with a custom wrapper class when one is
class="str-chat__message-bubble"
>
<div
aria-labelledby=":r1a: :r1b:"
class="str-chat__message-text"
tabindex="0"
>
<span
id=":r1a:"
style="border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; white-space: nowrap; width: 1px;"
>
aria/Message,
</span>
<div
class=""
data-testid="message-text-inner-wrapper"
>
<div>
<div
id=":r1b:"
>
<p>
hello world
</p>
Expand Down Expand Up @@ -144,14 +162,23 @@ exports[`<MessageText /> > should render with custom theme identifier in generat
class="str-chat__message-bubble"
>
<div
aria-labelledby=":r1e: :r1f:"
class="str-chat__message-text"
tabindex="0"
>
<span
id=":r1e:"
style="border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; white-space: nowrap; width: 1px;"
>
aria/Message,
</span>
<div
class=""
data-testid="message-text-inner-wrapper"
>
<div>
<div
id=":r1f:"
>
<p>
whatup?!
</p>
Expand Down
1 change: 1 addition & 0 deletions src/components/MessageComposer/WithDragAndDropUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export const WithDragAndDropUpload = ({
: false,
multiple: multipleUploads,
noClick: true,
noKeyboard: true,
onDrop: isWithinMessageComposerContext
? messageComposer.attachmentManager.uploadFiles
: handleDrop,
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@
"aria/Mark Message Unread": "Als ungelesen markieren",
"aria/Mark messages as read": "Nachrichten als gelesen markieren",
"aria/Menu": "Menü",
"aria/Message,": "Nachricht,",
"aria/Message Actions": "Nachrichtenaktionen",
"aria/Message from {{ user }},": "Nachricht von {{ user }},",
"aria/Message Options": "Nachrichtenoptionen",
"aria/Mute User": "Benutzer stummschalten",
"aria/Notifications": "Benachrichtigungen",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@
"aria/Mark Message Unread": "Mark Message Unread",
"aria/Mark messages as read": "Mark messages as read",
"aria/Menu": "Menu",
"aria/Message,": "Message,",
"aria/Message Actions": "Message Actions",
"aria/Message from {{ user }},": "Message from {{ user }},",
"aria/Message Options": "Message Options",
"aria/Mute User": "Mute User",
"aria/Notifications": "Notifications",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@
"aria/Mark Message Unread": "Marcar como no leído",
"aria/Mark messages as read": "Marcar mensajes como leídos",
"aria/Menu": "Menú",
"aria/Message,": "Mensaje,",
"aria/Message Actions": "Acciones del mensaje",
"aria/Message from {{ user }},": "Mensaje de {{ user }},",
"aria/Message Options": "Opciones de mensaje",
"aria/Mute User": "Silenciar usuario",
"aria/Notifications": "Notificaciones",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@
"aria/Mark Message Unread": "Marquer comme non lu",
"aria/Mark messages as read": "Marquer les messages comme lus",
"aria/Menu": "Menu",
"aria/Message,": "Message,",
"aria/Message Actions": "Actions du message",
"aria/Message from {{ user }},": "Message de {{ user }},",
"aria/Message Options": "Options du message",
"aria/Mute User": "Mettre en sourdine",
"aria/Notifications": "Notifications",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@
"aria/Mark Message Unread": "अपठित चिह्नित करें",
"aria/Mark messages as read": "संदेशों को पढ़ा हुआ चिह्नित करें",
"aria/Menu": "मेन्यू",
"aria/Message,": "संदेश,",
"aria/Message Actions": "संदेश कार्रवाइयाँ",
"aria/Message from {{ user }},": "{{ user }} का संदेश,",
"aria/Message Options": "संदेश विकल्प",
"aria/Mute User": "उपयोगकर्ता म्यूट करें",
"aria/Notifications": "सूचनाएं",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@
"aria/Mark Message Unread": "Contrassegna come non letto",
"aria/Mark messages as read": "Segna i messaggi come letti",
"aria/Menu": "Menu",
"aria/Message,": "Messaggio,",
"aria/Message Actions": "Azioni del messaggio",
"aria/Message from {{ user }},": "Messaggio di {{ user }},",
"aria/Message Options": "Opzioni di messaggio",
"aria/Mute User": "Mute utente",
"aria/Notifications": "Notifiche",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@
"aria/Mark Message Unread": "未読としてマーク",
"aria/Mark messages as read": "メッセージを既読にする",
"aria/Menu": "メニュー",
"aria/Message,": "メッセージ,",
"aria/Message Actions": "メッセージ操作",
"aria/Message from {{ user }},": "{{ user }}さんからのメッセージ,",
"aria/Message Options": "メッセージオプション",
"aria/Mute User": "ユーザーをミュート",
"aria/Notifications": "通知",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@
"aria/Mark Message Unread": "읽지 않음으로 표시",
"aria/Mark messages as read": "메시지를 읽음으로 표시",
"aria/Menu": "메뉴",
"aria/Message,": "메시지,",
"aria/Message Actions": "메시지 작업",
"aria/Message from {{ user }},": "{{ user }}의 메시지,",
"aria/Message Options": "메시지 옵션",
"aria/Mute User": "사용자 음소거",
"aria/Notifications": "알림",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@
"aria/Mark Message Unread": "Markeren als ongelezen",
"aria/Mark messages as read": "Markeer berichten als gelezen",
"aria/Menu": "Menu",
"aria/Message,": "Bericht,",
"aria/Message Actions": "Berichtacties",
"aria/Message from {{ user }},": "Bericht van {{ user }},",
"aria/Message Options": "Berichtopties",
"aria/Mute User": "Gebruiker dempen",
"aria/Notifications": "Meldingen",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@
"aria/Mark Message Unread": "Marcar como não lida",
"aria/Mark messages as read": "Marcar mensagens como lidas",
"aria/Menu": "Menu",
"aria/Message,": "Mensagem,",
"aria/Message Actions": "Ações da mensagem",
"aria/Message from {{ user }},": "Mensagem de {{ user }},",
"aria/Message Options": "Opções de mensagem",
"aria/Mute User": "Silenciar usuário",
"aria/Notifications": "Notificações",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@
"aria/Mark Message Unread": "Отметить как непрочитанное",
"aria/Mark messages as read": "Отметить сообщения как прочитанные",
"aria/Menu": "Меню",
"aria/Message,": "Сообщение,",
"aria/Message Actions": "Действия с сообщением",
"aria/Message from {{ user }},": "Сообщение от {{ user }},",
"aria/Message Options": "Параметры сообщения",
"aria/Mute User": "Отключить уведомления",
"aria/Notifications": "Уведомления",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@
"aria/Mark Message Unread": "Okunmamış olarak işaretle",
"aria/Mark messages as read": "Mesajları okundu olarak işaretle",
"aria/Menu": "Menü",
"aria/Message,": "Mesaj,",
"aria/Message Actions": "Mesaj eylemleri",
"aria/Message from {{ user }},": "{{ user }} adlı kullanıcıdan mesaj,",
"aria/Message Options": "Mesaj Seçenekleri",
"aria/Mute User": "Kullanıcıyı sustur",
"aria/Notifications": "Bildirimler",
Expand Down