From 5243e2a6f89bc9ce668525284d5ffdb4e2de7882 Mon Sep 17 00:00:00 2001 From: "nikola.nnaumovski" Date: Wed, 21 Oct 2026 16:37:39 +0200 Subject: [PATCH] feat(Poll): render poll translations in the reader's language With automatic translation enabled, the API returns a poll's question, options and answers translated, in name_i18n, text_i18n and answer_text_i18n. The default poll components now render the entry for client.user.language and fall back to the original text. - The question and options are translated in the message, the results and the full option list, and answers in the answers dialog. So is the question in the channel list preview and a quoted poll, and so is the option text in the channel list's vote preview. - A poll message starts in the translated view. MessageTranslationIndicator shows on it when the question or an option is translated, and "View original" switches the whole poll back along with any message text. - The indicator reads the live poll from client.polls, so translations that arrive after the first render show it without a reload. - New export: usePollTextTranslation(), for custom poll components that should follow the same translation view. --- ai-docs/ai-migration-v14-v15.md | 24 ++ .../ChannelListItem/__tests__/utils.test.ts | 50 ++++ src/components/ChannelListItem/utils.tsx | 13 +- src/components/Message/Message.tsx | 8 +- .../Message/MessageTranslationIndicator.tsx | 44 +++- .../Message/__tests__/Message.test.tsx | 28 +++ .../MessageTranslationIndicator.test.tsx | 127 +++++++++- .../Message/__tests__/QuotedMessage.test.tsx | 35 ++- .../MessageComposer/QuotedMessagePreview.tsx | 5 +- .../Poll/PollActions/PollAnswerList.tsx | 7 +- .../Poll/PollActions/PollOptionsFullList.tsx | 12 +- .../PollResults/PollOptionWithVotesHeader.tsx | 6 +- .../PollActions/PollResults/PollResults.tsx | 8 +- src/components/Poll/PollHeader.tsx | 8 +- src/components/Poll/PollOptionSelector.tsx | 6 +- .../Poll/__tests__/PollTranslation.test.tsx | 235 ++++++++++++++++++ src/components/Poll/hooks/index.ts | 1 + .../Poll/hooks/usePollTextTranslation.ts | 26 ++ .../Poll/utils/__tests__/translations.test.ts | 113 +++++++++ src/components/Poll/utils/translations.ts | 41 +++ 20 files changed, 767 insertions(+), 30 deletions(-) create mode 100644 src/components/Poll/__tests__/PollTranslation.test.tsx create mode 100644 src/components/Poll/hooks/usePollTextTranslation.ts create mode 100644 src/components/Poll/utils/__tests__/translations.test.ts create mode 100644 src/components/Poll/utils/translations.ts diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index f53e75ca3a..57b3364b86 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1882,6 +1882,30 @@ The gallery renders outside any message, so its header reads the current item. W - To change only the format, customize `timestamp.GalleryTimestamp`. - `MessageTimestamp` renders from its `message` prop outside a `MessageProvider`; without the prop it still requires one. +### New: polls render the reader's translation + +Not a break. With automatic translation enabled, the API translates a poll's question, options and answers, and returns them in `name_i18n`, `text_i18n` and `answer_text_i18n`. These have the same shape as `message.i18n`: one `_text` entry per language, plus `language`, the source language. The default poll components now render the entry for `client.user.language` and fall back to the original. That covers the poll in the message, the results, the full option list and the answers dialog. The channel list preview and a quoted poll show the translated question too. + +- A poll message starts in the translated view (`MessageContext.translationView`). `MessageTranslationIndicator` shows on it when the poll's question or an option is translated, and its "View original" toggle switches the whole poll back along with any message text. +- Translations that arrive later through `poll.updated` show without a reload, because the poll renders from `client.polls`, not from `message.poll`. +- A custom poll component (`PollHeader`, `PollOptionSelector`, ... through `WithComponents`) resolves its texts with `usePollTextTranslation()`, so it follows the same toggle: + +```tsx +import type { PollState } from 'stream-chat'; +import { usePollContext, usePollTextTranslation, useStateStore } from 'stream-chat-react'; + +const selector = ({ name, name_i18n }: PollState) => ({ name, name_i18n }); + +export const CustomPollHeader = () => { + const { poll } = usePollContext(); + const { name, name_i18n } = useStateStore(poll.state, selector); + const translate = usePollTextTranslation(); + return
{translate(name, name_i18n)}
; +}; +``` + +This translates poll data only. The SDK's own copy ("View results", "Add a comment", ...) still comes from `Streami18n` and the dictionaries you register ([section 18](#18-i18n)). + --- ## 13. Message composer diff --git a/src/components/ChannelListItem/__tests__/utils.test.ts b/src/components/ChannelListItem/__tests__/utils.test.ts index 354e824199..7bb6cbec7c 100644 --- a/src/components/ChannelListItem/__tests__/utils.test.ts +++ b/src/components/ChannelListItem/__tests__/utils.test.ts @@ -183,6 +183,56 @@ describe('ChannelPreview utils', () => { expect(getLatestMessagePreviewText(channel, tAria)).toBe('Poll: Lunch spot?'); }); + it('announces a poll by its question in the user language', async () => { + const channel = await getQueriedChannelInstance( + generateChannel({ + messages: [ + generateMessage({ + poll: { name: 'Lunch spot?', name_i18n: { es_text: '¿Dónde comer?' } }, + text: '', + }), + ], + }), + ); + expect(getLatestMessagePreviewText(channel, tAria, 'es')).toBe( + 'Poll: ¿Dónde comer?', + ); + expect(getLatestMessagePreviewText(channel, tAria, 'de')).toBe('Poll: Lunch spot?'); + }); + + it('announces a vote by the option text in the user language', async () => { + const voter = generateUser({ id: 'voter', name: 'Ana' }); + const vote = { + created_at: convertDateToTimestamp(), + id: 'vote-1', + option_id: 'apples', + poll_id: 'poll-voted', + updated_at: convertDateToTimestamp(), + user: voter, + user_id: voter.id, + }; + const channel = await getQueriedChannelInstance( + generateChannel({ + messages: [ + generateMessage({ + poll: { + id: 'poll-voted', + latest_votes_by_option: { apples: [vote] }, + name: 'Fruit?', + options: [ + { id: 'apples', text: 'Apples', text_i18n: { es_text: 'Manzanas' } }, + ], + vote_count: 1, + }, + text: '', + }), + ], + }), + ); + const preview = getLatestMessagePreview(channel, tAria, 'es'); + expect(preview).toBe('📊 Ana voted: Manzanas'); + }); + it('announces an attachment-only message with a localized type label', async () => { const channel = await getQueriedChannelInstance( generateChannel({ diff --git a/src/components/ChannelListItem/utils.tsx b/src/components/ChannelListItem/utils.tsx index 7204dd8027..ecad4d01eb 100644 --- a/src/components/ChannelListItem/utils.tsx +++ b/src/components/ChannelListItem/utils.tsx @@ -12,6 +12,7 @@ import type { TranslationContextValue } from '../../context/TranslationContext'; import type { PluggableList } from 'unified'; import { htmlToTextPlugin, imageToLink, plusPlusToEmphasis } from '../Message'; import { isMessageDeleted } from '../Message/utils'; +import { getPollTextTranslation } from '../Poll/utils/translations'; import remarkGfm from 'remark-gfm'; import { deriveChannelDisplayImage, @@ -130,6 +131,9 @@ const getLatestMessagePreviewParts = ( } if (poll) { + // Previews have no original/translated toggle: they always show the user's language. + const pollName = getPollTextTranslation(poll.name_i18n, userLanguage) ?? poll.name; + if (!poll.vote_count) { const createdBy = poll.created_by?.id === channel.getClient().userID @@ -139,13 +143,13 @@ const getLatestMessagePreviewParts = ( isUserMessageText: false, kind: 'poll', latestMessage, - pollName: poll.name, + pollName, text: t( 'channelListItem.created.text', '📊 {{createdBy}} created: {{ pollName}}', { createdBy, - pollName: poll.name, + pollName, }, ), }; @@ -161,12 +165,13 @@ const getLatestMessagePreviewParts = ( isUserMessageText: false, kind: 'poll', latestMessage, - pollName: poll.name, + pollName, text: t( 'channelListItem.voted.text', '📊 {{votedBy}} voted: {{pollOptionText}}', { - pollOptionText: option.text, + pollOptionText: + getPollTextTranslation(option.text_i18n, userLanguage) ?? option.text, votedBy: latestVote?.user?.id === channel.getClient().userID ? t('common.you.label', 'You') diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 7d961fc0fd..79b8c02776 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -66,7 +66,13 @@ const MessageWithContext = (props: MessageWithContextProps) => { const { getTranslationView, setTranslationView: setTranslationViewInContext } = useMessageTranslationViewContext(); - const translationView = getTranslationView(message.id, !!message.i18n); + // A poll message has no `i18n` of its own, and its poll's translations live in the poll's state, not + // in `message.poll`. Defaulting every poll message to 'translated' is safe: a poll text without a + // translation falls back to its original. + const translationView = getTranslationView( + message.id, + !!message.i18n || !!message.poll_id, + ); const setTranslationView = useCallback( (view: 'original' | 'translated') => setTranslationViewInContext(message.id, view), [message.id, setTranslationViewInContext], diff --git a/src/components/Message/MessageTranslationIndicator.tsx b/src/components/Message/MessageTranslationIndicator.tsx index 264881e2ea..c2a8abf989 100644 --- a/src/components/Message/MessageTranslationIndicator.tsx +++ b/src/components/Message/MessageTranslationIndicator.tsx @@ -1,12 +1,25 @@ -import type { LocalMessage } from 'stream-chat'; -import React, { useCallback, useMemo } from 'react'; +import type { LocalMessage, PollState } from 'stream-chat'; +import React, { useCallback, useContext, useMemo } from 'react'; import { + ChatContext, getTranslatedMessageText, useComponentContextIcons, useMessageContext, useTranslationContext, } from '../../context'; import { Button } from '../Button'; +import { useStateStore } from '../../store'; +import { + getPollSourceLanguage, + pollHasTranslation, + type PollTexts, +} from '../Poll/utils/translations'; + +const pollTextsSelector = ({ name, name_i18n, options }: PollState): PollTexts => ({ + name, + name_i18n, + options, +}); export type TranslationIndicatorProps = { message?: LocalMessage; @@ -24,17 +37,29 @@ export const MessageTranslationIndicator = ({ } = useMessageContext(); const message = propMessage ?? contextMessage; + // The poll renders from its live state, which poll events update; `message.poll` is the snapshot + // the message was loaded with. Read without `useChatContext()`, which throws outside . + const { client } = useContext(ChatContext) ?? {}; + const livePoll = message?.poll_id + ? client?.polls.fromState(message.poll_id) + : undefined; + const livePollTexts = useStateStore(livePoll?.state, pollTextsSelector); + const pollTexts = livePollTexts ?? message?.poll; + const translatedTextForUser = useMemo( () => getTranslatedMessageText({ language: userLanguage, message }), [userLanguage, message], ); + // A poll message is translated when its poll is (question or options), whether or not the message + // has text of its own. const hasTranslationForUserLanguage = useMemo( () => - translatedTextForUser != null && - message?.text !== undefined && - translatedTextForUser !== message.text, - [translatedTextForUser, message?.text], + (translatedTextForUser != null && + message?.text !== undefined && + translatedTextForUser !== message.text) || + pollHasTranslation(pollTexts, userLanguage), + [translatedTextForUser, message?.text, pollTexts, userLanguage], ); const viewingOriginal = useMemo( @@ -49,7 +74,8 @@ export const MessageTranslationIndicator = ({ }, [setTranslationView, viewingOriginal]); const sourceLanguageName = useMemo(() => { - const sourceLanguageCode = message?.i18n?.language; + const sourceLanguageCode = + message?.i18n?.language ?? getPollSourceLanguage(pollTexts); if (!sourceLanguageCode) return ''; // `language.*` keys are part of the catalog now (core derives them from the same // `TranslationLanguage` union this code is), so the key is checked at compile time rather than @@ -62,9 +88,9 @@ export const MessageTranslationIndicator = ({ const languageKey = `language.${sourceLanguageCode}` as const; const translatedName = t(languageKey); return translatedName === languageKey ? sourceLanguageCode : translatedName; - }, [message?.i18n?.language, t]); + }, [message?.i18n?.language, pollTexts, t]); - if (!message?.i18n || !setTranslationView) return null; + if (!message || !setTranslationView) return null; if (!hasTranslationForUserLanguage) return null; return ( diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index b92476149f..5b74df77bf 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -11,6 +11,7 @@ import { useMessageContext } from '../../../context/MessageContext'; import type { MessageContextValue } from '../../../context/MessageContext'; import { generateMessage, + generatePoll, generateReaction, generateUser, getOrCreateChannelApi, @@ -195,6 +196,33 @@ describe(' component', () => { beforeEach(vi.clearAllMocks); afterEach(cleanup); + describe('default translation view', () => { + // Poll translations live in the poll's state, not in `message.poll`, so the default can't depend on + // them; 'translated' is safe because a poll text without a translation falls back to its original. + it('is translated for a poll message', async () => { + let context: Record = {}; + const poll = generatePoll({ id: 'poll-default-view' }); + await renderComponent({ + contextCallback: (ctx) => (context = ctx), + message: generateMessage({ poll, poll_id: poll.id, text: '' }), + }); + expect(context.translationView).toBe('translated'); + }); + + it('is original for a message with neither i18n nor a poll', async () => { + let context: Record = {}; + await renderComponent({ + contextCallback: (ctx) => (context = ctx), + message: generateMessage({ + i18n: undefined, + poll: undefined, + poll_id: undefined, + }), + }); + expect(context.translationView).toBe('original'); + }); + }); + it('should not pass custom props to its Message child component', async () => { await renderComponentWithMessage({ customProp: 'some custom prop', diff --git a/src/components/Message/__tests__/MessageTranslationIndicator.test.tsx b/src/components/Message/__tests__/MessageTranslationIndicator.test.tsx index 81ff5c75cd..eda9c62248 100644 --- a/src/components/Message/__tests__/MessageTranslationIndicator.test.tsx +++ b/src/components/Message/__tests__/MessageTranslationIndicator.test.tsx @@ -1,10 +1,16 @@ -import { render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { MessageProvider, TranslationProvider } from '../../../context'; +import { ChatProvider, MessageProvider, TranslationProvider } from '../../../context'; import { Streami18n } from '../../../i18n/Streami18n'; import type { StreamTFunction } from '../../../i18n/types'; -import { mockMessageContext } from '../../../mock-builders'; +import { + convertDateToTimestamp, + generatePoll, + getTestClientWithUser, + mockChatContext, + mockMessageContext, +} from '../../../mock-builders'; import { MessageTranslationIndicator } from '../MessageTranslationIndicator'; /** @@ -58,4 +64,119 @@ describe('MessageTranslationIndicator', () => { expect(screen.getByText('Translated from xx')).toBeInTheDocument(); expect(screen.queryByText(/language\.xx/)).not.toBeInTheDocument(); }); + + describe('poll messages', () => { + const pollMessage = { + poll: { + latest_answers: [], + name: 'Favourite fruit?', + name_i18n: { es_text: '¿Fruta favorita?', language: 'en' }, + options: [{ id: 'apples', text: 'Apples', text_i18n: { es_text: 'Manzanas' } }], + }, + text: '', + type: 'regular', + }; + + const renderPollIndicator = async ( + userLanguage: string, + setTranslationView = vi.fn(), + ) => { + const i18n = new Streami18n({ logger: () => {} }); + const { t, tDateTimeParser } = await i18n.init(); + render( + + + + + , + ); + return setTranslationView; + }; + + it('renders for a poll-only message whose poll is translated', async () => { + await renderPollIndicator('es'); + expect(screen.getByText('Translated from English')).toBeInTheDocument(); + }); + + it('switches the message (and so its poll) to the original', async () => { + const setTranslationView = await renderPollIndicator('es'); + fireEvent.click(screen.getByText('View original')); + expect(setTranslationView).toHaveBeenCalledWith('original'); + }); + + it('stays hidden when the poll has no translation for the user language', async () => { + await renderPollIndicator('de'); + expect(screen.queryByText(/Translated/)).not.toBeInTheDocument(); + }); + + // The poll renders from its live state; `message.poll` is the snapshot it was loaded with and is + // never updated by poll events. + it('appears when a poll.updated event brings translations after the first render', async () => { + const client = await getTestClientWithUser(); + client.polls.registerSubscriptions(); + const pollData = generatePoll({ + id: 'live-poll', + latest_answers: [], + name: 'Favourite fruit?', + name_i18n: undefined, + options: [{ id: 'apples', text: 'Apples' }], + }); + const message = { + id: 'm-live', + poll: pollData, + poll_id: pollData.id, + text: '', + type: 'regular', + }; + client.polls.hydratePollCache([message as never]); + + const i18n = new Streami18n({ logger: () => {} }); + const { t, tDateTimeParser } = await i18n.init(); + render( + + + + + + + , + ); + expect(screen.queryByText(/Translated/)).not.toBeInTheDocument(); + + act(() => { + client.dispatchEvent({ + created_at: convertDateToTimestamp(), + poll: { + ...pollData, + options: [ + { + id: 'apples', + text: 'Apples', + text_i18n: { es_text: 'Manzanas', language: 'en' }, + }, + ], + }, + type: 'poll.updated', + } as never); + }); + + expect(screen.getByText('Translated from English')).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/Message/__tests__/QuotedMessage.test.tsx b/src/components/Message/__tests__/QuotedMessage.test.tsx index c1a6977603..51b6a3be12 100644 --- a/src/components/Message/__tests__/QuotedMessage.test.tsx +++ b/src/components/Message/__tests__/QuotedMessage.test.tsx @@ -52,6 +52,7 @@ async function renderQuotedMessage({ customChannel, customClient, customProps, + userLanguage = 'en', }: any = {}) { const { channels: [channel], @@ -72,7 +73,7 @@ async function renderQuotedMessage({ value={mockTranslationContextValue({ t: mockT, tDateTimeParser: customDateTimeParser, - userLanguage: 'en', + userLanguage, })} > { expect(results).toHaveNoViolations(); }); + describe('quoted poll', () => { + const quotedPollMessage = { + quoted_message: { + // Server messages always carry `attachments`; the preview finds the poll alongside them. + attachments: [], + poll: { + name: 'Favourite fruit?', + name_i18n: { es_text: '¿Fruta favorita?', language: 'en' }, + }, + text: '', + }, + }; + + it('shows the poll question in the user language', async () => { + const { container, findByText } = await renderQuotedMessage({ + customProps: { message: quotedPollMessage }, + userLanguage: 'es', + }); + expect(await findByText('¿Fruta favorita?')).toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('shows the original question without a translation for the user language', async () => { + const { findByText } = await renderQuotedMessage({ + customProps: { message: quotedPollMessage }, + userLanguage: 'de', + }); + expect(await findByText('Favourite fruit?')).toBeInTheDocument(); + }); + }); + it('renders built-in, role, and user-group mentions inside quoted message text', async () => { const messageText = 'hey @channel @here @admin @Backend Team'; const { container, findByText } = await renderQuotedMessage({ diff --git a/src/components/MessageComposer/QuotedMessagePreview.tsx b/src/components/MessageComposer/QuotedMessagePreview.tsx index 7b9364a1f4..a21e6f3006 100644 --- a/src/components/MessageComposer/QuotedMessagePreview.tsx +++ b/src/components/MessageComposer/QuotedMessagePreview.tsx @@ -41,6 +41,7 @@ import { FileIcon } from '../FileIcon'; import { QuotedMessageIndicator } from './QuotedMessageIndicator'; import { getRenderTextMentionEntities } from '../Message/renderText/rehypePlugins'; import { isDeletedMessage } from '../MessageList'; +import { getPollTextTranslation } from '../Poll/utils/translations'; import { useComponentContextIcons } from '../../context'; import type { IconSlots } from '../Icons/slots'; @@ -389,7 +390,8 @@ export const QuotedMessagePreviewUI = ({ } else if (!quotedMessageText) { if (previewType === 'poll') { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - renderedText = quotedMessage.poll!.name; + const poll = quotedMessage.poll!; + renderedText = getPollTextTranslation(poll.name_i18n, userLanguage) ?? poll.name; } else if (previewType === 'location') { renderedText = t('common.liveLocation.text', 'Live location'); } else if (previewType === 'voice') { @@ -456,6 +458,7 @@ export const QuotedMessagePreviewUI = ({ quotedMessageText, renderText, t, + userLanguage, ]); const isOwnMessage = client.user?.id === quotedMessage?.user?.id; diff --git a/src/components/Poll/PollActions/PollAnswerList.tsx b/src/components/Poll/PollActions/PollAnswerList.tsx index 12a645deea..b8772425a9 100644 --- a/src/components/Poll/PollActions/PollAnswerList.tsx +++ b/src/components/Poll/PollActions/PollAnswerList.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Button } from '../../Button'; import { Viewer } from '../../Dialog'; import { PollVote } from '../PollVote'; -import { usePollAnswerPagination } from '../hooks'; +import { usePollAnswerPagination, usePollTextTranslation } from '../hooks'; import { InfiniteScrollPaginator } from '../../InfiniteScrollPaginator/InfiniteScrollPaginator'; import { LoadingIndicator } from '../../Loading'; import { useStateStore } from '../../../store'; @@ -34,6 +34,7 @@ export const PollAnswerList = ({ onUpdateOwnAnswerClick }: PollAnswerListProps) const { poll } = usePollContext(); const { close } = useModalContext(); const { is_closed } = useStateStore(poll.state, pollStateSelector); + const translate = usePollTextTranslation(); const { answers, error, hasNextPage, loading, loadMore } = usePollAnswerPagination(); @@ -54,7 +55,9 @@ export const PollAnswerList = ({ onUpdateOwnAnswerClick }: PollAnswerListProps)
{answer.answer_text && ( -

{answer.answer_text}

+

+ {translate(answer.answer_text, answer.answer_text_i18n)} +

)}
diff --git a/src/components/Poll/PollActions/PollOptionsFullList.tsx b/src/components/Poll/PollActions/PollOptionsFullList.tsx index 3c73f24366..319bb6302e 100644 --- a/src/components/Poll/PollActions/PollOptionsFullList.tsx +++ b/src/components/Poll/PollActions/PollOptionsFullList.tsx @@ -6,17 +6,23 @@ import { useModalContext, usePollContext, useTranslationContext } from '../../.. import type { PollState } from 'stream-chat'; import { PollQuestion } from './PollQuestion'; +import { usePollTextTranslation } from '../hooks'; -type PollStateSelectorReturnValue = { name: string }; +type PollStateSelectorReturnValue = { + name: string; + name_i18n: PollState['name_i18n']; +}; const pollStateSelector = (nextValue: PollState): PollStateSelectorReturnValue => ({ name: nextValue.name, + name_i18n: nextValue.name_i18n, }); export const PollOptionsFullList = () => { const { t } = useTranslationContext(); const { poll } = usePollContext(); const { close } = useModalContext(); - const { name } = useStateStore(poll.state, pollStateSelector); + const { name, name_i18n } = useStateStore(poll.state, pollStateSelector); + const translate = usePollTextTranslation(); return ( @@ -29,7 +35,7 @@ export const PollOptionsFullList = () => { title={t('poll.optionsFull.pollOptions.title', 'Poll Options')} /> - + diff --git a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx index a81e2052f8..70f484b953 100644 --- a/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx +++ b/src/components/Poll/PollActions/PollResults/PollOptionWithVotesHeader.tsx @@ -6,6 +6,7 @@ import { useTranslationContext, } from '../../../../context'; import type { PollOptionResponseData, PollState } from 'stream-chat'; +import { usePollTextTranslation } from '../../hooks'; type PollStateSelectorReturnValue = { maxVotedOptionIds: string[]; @@ -57,6 +58,7 @@ export const PollOptionWithVotesHeader = ({ optionOrderNumber, }: PollOptionWithVotesHeaderProps) => { const { t } = useTranslationContext(); + const translate = usePollTextTranslation(); return (
@@ -66,7 +68,9 @@ export const PollOptionWithVotesHeader = ({ })}
-
{option.text}
+
+ {translate(option.text, option.text_i18n)} +
diff --git a/src/components/Poll/PollActions/PollResults/PollResults.tsx b/src/components/Poll/PollActions/PollResults/PollResults.tsx index eb1b0392be..249bdff6b8 100644 --- a/src/components/Poll/PollActions/PollResults/PollResults.tsx +++ b/src/components/Poll/PollActions/PollResults/PollResults.tsx @@ -12,14 +12,17 @@ import type { PollOptionResponseData, PollState } from 'stream-chat'; import { COUNT_OPTION_VOTES_PREVIEW } from '../../constants'; import { PollQuestion } from '../PollQuestion'; import { PollOptionWithVotesList } from './PollOptionWithVotesList'; +import { usePollTextTranslation } from '../../hooks'; const pollStateSelector = ({ name, + name_i18n, options, vote_count, vote_counts_by_option, }: PollState) => ({ name, + name_i18n, options: [...options], vote_count, vote_counts_by_option, @@ -29,10 +32,11 @@ export const PollResults = () => { const { t } = useTranslationContext(); const { poll } = usePollContext(); const { close } = useModalContext(); - const { name, options, vote_count, vote_counts_by_option } = useStateStore( + const { name, name_i18n, options, vote_count, vote_counts_by_option } = useStateStore( poll.state, pollStateSelector, ); + const translate = usePollTextTranslation(); const [optionToView, setOptionToView] = useState<{ option: PollOptionResponseData; optionOrderNumber: number; @@ -75,7 +79,7 @@ export const PollResults = () => { title={t('poll.results.pollResults.title', 'Poll Results')} /> - +
{options diff --git a/src/components/Poll/PollHeader.tsx b/src/components/Poll/PollHeader.tsx index 4380a437ef..c854aebafd 100644 --- a/src/components/Poll/PollHeader.tsx +++ b/src/components/Poll/PollHeader.tsx @@ -2,12 +2,14 @@ import React, { useMemo } from 'react'; import { usePollContext, useTranslationContext } from '../../context'; import { useStateStore } from '../../store'; import type { PollOptionResponseData, PollState } from 'stream-chat'; +import { usePollTextTranslation } from './hooks'; type PollStateSelectorReturnValue = { enforce_unique_vote: boolean; is_closed: boolean | undefined; max_votes_allowed: number | undefined; name: string; + name_i18n: PollState['name_i18n']; options: PollOptionResponseData[]; }; const pollStateSelector = (nextValue: PollState): PollStateSelectorReturnValue => ({ @@ -15,14 +17,16 @@ const pollStateSelector = (nextValue: PollState): PollStateSelectorReturnValue = is_closed: nextValue.is_closed, max_votes_allowed: nextValue.max_votes_allowed, name: nextValue.name, + name_i18n: nextValue.name_i18n, options: nextValue.options, }); export const PollHeader = () => { const { t } = useTranslationContext(); + const translate = usePollTextTranslation(); const { poll } = usePollContext(); - const { enforce_unique_vote, is_closed, max_votes_allowed, name, options } = + const { enforce_unique_vote, is_closed, max_votes_allowed, name, name_i18n, options } = useStateStore(poll.state, pollStateSelector); const selectionInstructions = useMemo(() => { @@ -44,7 +48,7 @@ export const PollHeader = () => { return (
-
{name}
+
{translate(name, name_i18n)}
{selectionInstructions}
); diff --git a/src/components/Poll/PollOptionSelector.tsx b/src/components/Poll/PollOptionSelector.tsx index eecbce4bba..6b58f0e497 100644 --- a/src/components/Poll/PollOptionSelector.tsx +++ b/src/components/Poll/PollOptionSelector.tsx @@ -18,6 +18,7 @@ import { import { useStateStore } from '../../store'; import { Checkbox } from '../Form'; import { useChannelCapabilities } from '../Channel/hooks/useChannelCapabilities'; +import { usePollTextTranslation } from './hooks'; type AmountBarProps = { amount: number; @@ -69,6 +70,7 @@ export const PollOptionSelector = ({ const { t } = useTranslationContext(); const channelCapabilities = useChannelCapabilities({ cid: channel.cid }); const { message } = useMessageContext(); + const translate = usePollTextTranslation(); const { AvatarStack = DefaultAvatarStack, extractDisplayInfo = defaultExtractDisplayInfo, @@ -132,7 +134,9 @@ export const PollOptionSelector = ({ > {canCastVote && }
-

{option.text}

+

+ {translate(option.text, option.text_i18n)} +

{displayAvatarCount && voting_visibility === 'public' && (
diff --git a/src/components/Poll/__tests__/PollTranslation.test.tsx b/src/components/Poll/__tests__/PollTranslation.test.tsx new file mode 100644 index 0000000000..c9cd368a9a --- /dev/null +++ b/src/components/Poll/__tests__/PollTranslation.test.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { Poll } from 'stream-chat'; +import type { Channel, StreamChat } from 'stream-chat'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { render, screen, waitFor } from '@testing-library/react'; +import { axe } from '../../../../axe-helper'; +import { PollHeader } from '../PollHeader'; +import { PollOptionList } from '../PollOptionList'; +import { PollAnswerList } from '../PollActions/PollAnswerList'; +import { PollOptionsFullList } from '../PollActions/PollOptionsFullList'; +import { PollResults } from '../PollActions/PollResults/PollResults'; +import { + ChannelInstanceProvider, + ChatProvider, + MessageProvider, + ModalDialogManagerProvider, + PollProvider, + TranslationProvider, +} from '../../../context'; +import { + generateChannelState, + generateMessage, + generatePoll, + getTestClientWithUser, + mockChatContext, + mockMessageContext, + mockTranslationContextValue, +} from '../../../mock-builders'; + +const TITLE_SELECTOR = '.str-chat__poll-title'; +const OPTION_TEXT_SELECTOR = '.str-chat__poll-option-text'; +const QUESTION_SELECTOR = '.str-chat__modal__poll-question__text'; +const RESULTS_OPTION_TEXT_SELECTOR = '.str-chat__poll-option__option-text'; +const ANSWER_TEXT_SELECTOR = '.str-chat__poll-answer__text'; + +const pollData = generatePoll({ + latest_answers: [], + latest_votes_by_option: {}, + name: 'Favourite fruit?', + name_i18n: { es_text: '¿Fruta favorita?', language: 'en' }, + options: [ + { id: 'apples', text: 'Apples', text_i18n: { es_text: 'Manzanas' } }, + { id: 'bananas', text: 'Bananas', text_i18n: { es_text: 'Plátanos' } }, + { id: 'cherries', text: 'Cherries' }, + ], + own_votes: [], + vote_count: 0, + vote_counts_by_option: {}, +}); + +const answer = { + answer_text: 'Tasty', + answer_text_i18n: { es_text: 'Sabroso', language: 'en' }, + created_at: pollData.created_at, + id: 'answer-1', + is_answer: true, + option_id: '', + poll_id: pollData.id, + updated_at: pollData.updated_at, + user: { id: 'commenter', name: 'Commenter' }, +}; + +const makePoll = () => { + const poll = new Poll({ client: fromPartial({}), poll: pollData }); + vi.spyOn(poll, 'queryAnswers').mockResolvedValue( + fromPartial({ next: undefined, votes: [answer] }), + ); + return poll; +}; + +const channel = fromPartial({ + cid: 'messaging:poll-translation-test', + state: generateChannelState({ ownCapabilities: ['cast-poll-vote'] }), +}); + +describe('poll translations', () => { + let chatClient: StreamChat; + + beforeAll(async () => { + chatClient = await getTestClientWithUser(); + }); + + const renderPoll = ({ + children, + translationView, + userLanguage, + withMessage = true, + }: { + children?: React.ReactNode; + translationView?: 'original' | 'translated'; + userLanguage: string; + withMessage?: boolean; + }) => { + const poll = makePoll(); + const content = ( + + {children ?? ( + <> + + {withMessage && } + + )} + + ); + return render( + + + + + {withMessage ? ( + + {content} + + ) : ( + content + )} + + + + , + ); + }; + + const textsOf = (container: HTMLElement, selector: string) => + [...container.querySelectorAll(selector)].map((el) => el.textContent); + const optionTexts = (container: HTMLElement) => + textsOf(container, OPTION_TEXT_SELECTOR); + + it('renders the question and options in the user language', async () => { + const { container } = renderPoll({ + translationView: 'translated', + userLanguage: 'es', + }); + expect(container.querySelector(TITLE_SELECTOR)).toHaveTextContent('¿Fruta favorita?'); + // An option without a translation falls back to its original text. + expect(optionTexts(container)).toEqual(['Manzanas', 'Plátanos', 'Cherries']); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("renders the originals when the message's view is 'original'", () => { + const { container } = renderPoll({ translationView: 'original', userLanguage: 'es' }); + expect(container.querySelector(TITLE_SELECTOR)).toHaveTextContent('Favourite fruit?'); + expect(optionTexts(container)).toEqual(['Apples', 'Bananas', 'Cherries']); + }); + + it('renders the originals when there is no translation for the user language', () => { + const { container } = renderPoll({ + translationView: 'translated', + userLanguage: 'de', + }); + expect(container.querySelector(TITLE_SELECTOR)).toHaveTextContent('Favourite fruit?'); + expect(optionTexts(container)).toEqual(['Apples', 'Bananas', 'Cherries']); + }); + + it('adds no MessageContext requirement to the poll header', () => { + renderPoll({ userLanguage: 'es', withMessage: false }); + expect(screen.getByText('¿Fruta favorita?')).toBeInTheDocument(); + }); + + describe('dialogs', () => { + it('translates the question and options in the results', async () => { + const { container } = renderPoll({ + children: , + translationView: 'translated', + userLanguage: 'es', + }); + expect(container.querySelector(QUESTION_SELECTOR)).toHaveTextContent( + '¿Fruta favorita?', + ); + expect(textsOf(container, RESULTS_OPTION_TEXT_SELECTOR)).toEqual([ + 'Manzanas', + 'Plátanos', + 'Cherries', + ]); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("shows the originals in the results when the message's view is 'original'", () => { + const { container } = renderPoll({ + children: , + translationView: 'original', + userLanguage: 'es', + }); + expect(container.querySelector(QUESTION_SELECTOR)).toHaveTextContent( + 'Favourite fruit?', + ); + expect(textsOf(container, RESULTS_OPTION_TEXT_SELECTOR)).toEqual([ + 'Apples', + 'Bananas', + 'Cherries', + ]); + }); + + it('translates the question and options in the full option list', async () => { + const { container } = renderPoll({ + children: , + translationView: 'translated', + userLanguage: 'es', + }); + expect(container.querySelector(QUESTION_SELECTOR)).toHaveTextContent( + '¿Fruta favorita?', + ); + expect(optionTexts(container)).toEqual(['Manzanas', 'Plátanos', 'Cherries']); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('translates the comments in the answer list', async () => { + const { container } = renderPoll({ + children: , + translationView: 'translated', + userLanguage: 'es', + }); + await waitFor(() => + expect(textsOf(container, ANSWER_TEXT_SELECTOR)).toEqual(['Sabroso']), + ); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("shows the original comments when the message's view is 'original'", async () => { + const { container } = renderPoll({ + children: , + translationView: 'original', + userLanguage: 'es', + }); + await waitFor(() => + expect(textsOf(container, ANSWER_TEXT_SELECTOR)).toEqual(['Tasty']), + ); + }); + }); +}); diff --git a/src/components/Poll/hooks/index.ts b/src/components/Poll/hooks/index.ts index 6f979aadd5..0c0dda4ae9 100644 --- a/src/components/Poll/hooks/index.ts +++ b/src/components/Poll/hooks/index.ts @@ -1,2 +1,3 @@ export * from './usePollAnswerPagination'; export * from './usePollOptionVotesPagination'; +export * from './usePollTextTranslation'; diff --git a/src/components/Poll/hooks/usePollTextTranslation.ts b/src/components/Poll/hooks/usePollTextTranslation.ts new file mode 100644 index 0000000000..509c53a441 --- /dev/null +++ b/src/components/Poll/hooks/usePollTextTranslation.ts @@ -0,0 +1,26 @@ +import { useCallback, useContext } from 'react'; +import type { PollResponseData } from 'stream-chat'; + +import { MessageContext, useTranslationContext } from '../../../context'; +import { getPollTextTranslation } from '../utils/translations'; + +/** + * Returns `translate(original, i18n)`, which resolves a poll text (question, option, answer) for + * the current user language. It follows the enclosing message's translation view, so the message's + * "View original" toggle switches its poll too. Falls back to `original` when there is no + * translation for the user language. + */ +export const usePollTextTranslation = () => { + const { userLanguage } = useTranslationContext(); + // Read without `useMessageContext()`, which throws: the poll parts using this hook (header, + // results, option list, answers) did not require a MessageContext before, and must not start to. + const { translationView = 'translated' } = useContext(MessageContext) ?? {}; + + return useCallback( + (original: string, i18n: PollResponseData['name_i18n']): string => + translationView === 'original' + ? original + : (getPollTextTranslation(i18n, userLanguage) ?? original), + [translationView, userLanguage], + ); +}; diff --git a/src/components/Poll/utils/__tests__/translations.test.ts b/src/components/Poll/utils/__tests__/translations.test.ts new file mode 100644 index 0000000000..2b0c600a86 --- /dev/null +++ b/src/components/Poll/utils/__tests__/translations.test.ts @@ -0,0 +1,113 @@ +import { generatePoll } from '../../../../mock-builders'; +import { + getPollSourceLanguage, + getPollTextTranslation, + pollHasTranslation, +} from '../translations'; + +describe('getPollTextTranslation', () => { + it('reads the `_text` entry', () => { + expect(getPollTextTranslation({ es_text: 'Manzanas', language: 'en' }, 'es')).toBe( + 'Manzanas', + ); + }); + + it('reads region-specific codes', () => { + expect(getPollTextTranslation({ 'es-MX_text': 'Manzanas' }, 'es-MX')).toBe( + 'Manzanas', + ); + }); + + it('never treats the source-language marker as a translation', () => { + expect(getPollTextTranslation({ language: 'en' }, 'language')).toBeUndefined(); + }); + + it('returns undefined without a translation for the language', () => { + expect(getPollTextTranslation({ fr: 'Pommes' }, 'es')).toBeUndefined(); + expect(getPollTextTranslation(undefined, 'es')).toBeUndefined(); + }); +}); + +describe('pollHasTranslation', () => { + const untranslated = { + latest_answers: [], + name: 'Fruit?', + name_i18n: undefined, + options: [{ id: 'a', text: 'Apples' }], + }; + + it('is false for a poll with no translations', () => { + expect(pollHasTranslation(generatePoll(untranslated), 'es')).toBe(false); + expect(pollHasTranslation(undefined, 'es')).toBe(false); + }); + + it('is true when the question is translated', () => { + const poll = generatePoll({ ...untranslated, name_i18n: { es_text: '¿Fruta?' } }); + expect(pollHasTranslation(poll, 'es')).toBe(true); + }); + + it('is true when only an option is translated', () => { + const poll = generatePoll({ + ...untranslated, + options: [{ id: 'a', text: 'Apples', text_i18n: { es_text: 'Manzanas' } }], + }); + expect(pollHasTranslation(poll, 'es')).toBe(true); + }); + + it('ignores translated answers, which only show inside the answers dialog', () => { + const poll = generatePoll({ + ...untranslated, + latest_answers: [ + { + answer_text: 'Tasty', + answer_text_i18n: { es_text: 'Sabroso' }, + id: 'ans', + is_answer: true, + }, + ], + }); + expect(pollHasTranslation(poll, 'es')).toBe(false); + }); + + it('ignores a translation identical to the original (the source language)', () => { + const poll = generatePoll({ ...untranslated, name_i18n: { en_text: 'Fruit?' } }); + expect(pollHasTranslation(poll, 'en')).toBe(false); + }); +}); + +describe('getPollSourceLanguage', () => { + it('reads `language` from the question translations', () => { + expect( + getPollSourceLanguage({ + name: 'Fruit?', + name_i18n: { es_text: '¿Fruta?', language: 'en' }, + options: [], + }), + ).toBe('en'); + expect( + getPollSourceLanguage({ + name: 'Fruit?', + name_i18n: undefined, + options: [{ custom: {}, id: 'a', text: 'Apples' }], + }), + ).toBeUndefined(); + }); + + it('falls back to an option when the question has no translations', () => { + expect( + getPollSourceLanguage({ + name: 'Fruit?', + name_i18n: undefined, + options: [ + { custom: {}, id: 'a', text: 'Apples' }, + { + custom: {}, + id: 'b', + text: 'Bananas', + text_i18n: { es_text: 'Plátanos', language: 'en' }, + }, + ], + }), + ).toBe('en'); + }); +}); diff --git a/src/components/Poll/utils/translations.ts b/src/components/Poll/utils/translations.ts new file mode 100644 index 0000000000..d61ccc8f46 --- /dev/null +++ b/src/components/Poll/utils/translations.ts @@ -0,0 +1,41 @@ +import type { PollResponseData } from 'stream-chat'; + +/** + * The translation of one poll text into `language`, or undefined if there is none. + * + * The single place that knows the key format of the backend's poll translation maps (`name_i18n`, + * an option's `text_i18n`, an answer's `answer_text_i18n`). They have the same shape as + * `message.i18n`: one `_text` entry per language, plus `language`, the source language. + */ +export const getPollTextTranslation = ( + i18n: PollResponseData['name_i18n'], + language: string, +): string | undefined => i18n?.[`${language}_text`]; + +/** The poll texts the message-level translation indicator reads. */ +export type PollTexts = Pick; + +/** + * Whether the poll's question or any of its options has a translation into `language` that differs + * from the original. Answers are left out: they are only visible inside the answers dialog, so they + * must not put a translation indicator on the message. + */ +export const pollHasTranslation = ( + poll: PollTexts | undefined, + language: string, +): boolean => { + if (!poll) return false; + const differs = (i18n: PollResponseData['name_i18n'], original: string) => { + const translation = getPollTextTranslation(i18n, language); + return !!translation && translation !== original; + }; + return ( + differs(poll.name_i18n, poll.name) || + !!poll.options?.some((option) => differs(option.text_i18n, option.text)) + ); +}; + +/** The poll's source language code, when the backend sends one (`language` in a translation map). */ +export const getPollSourceLanguage = (poll: PollTexts | undefined): string | undefined => + poll?.name_i18n?.language ?? + poll?.options?.find((option) => option.text_i18n?.language)?.text_i18n?.language;