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;