diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx index 1b8f6e72db..12faa05394 100644 --- a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx +++ b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx @@ -1,9 +1,10 @@ import React, { PropsWithChildren } from 'react'; import { act, renderHook } from '@testing-library/react-native'; -import { Poll, PollResponse, PollVote, StreamChat } from 'stream-chat'; +import { Poll, PollResponse, PollVote, StreamChat, TranslationLanguages } from 'stream-chat'; import { ChatProvider } from '../../../../contexts/chatContext/ChatContext'; +import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; import { useChannelPreviewPollLabel } from '../useChannelPreviewPollLabel'; const OWN_USER = { id: 'own-user' }; @@ -20,7 +21,7 @@ const generateVote = (id: string, optionId: string, createdAt: string): PollVote user_id: OWN_USER.id, }); -const setup = (ownVotes: PollVote[]) => { +const setup = (ownVotes: PollVote[], userLanguage: TranslationLanguages = 'en') => { const client = { removePollVote: jest.fn(() => new Promise(() => {})), user: OWN_USER, @@ -41,7 +42,7 @@ const setup = (ownVotes: PollVote[]) => { latest_votes_by_option: latestVotesByOption, name: 'Poll', options: [ - { id: 'option-a', text: 'A' }, + { id: 'option-a', text: 'A', text_i18n: { es_text: 'A (es)', language: 'en' } }, { id: 'option-b', text: 'B' }, ], own_votes: ownVotes, @@ -52,7 +53,19 @@ const setup = (ownVotes: PollVote[]) => { (client as unknown as { polls: unknown }).polls = { fromState: () => poll }; const wrapper = ({ children }: PropsWithChildren) => ( - {children} + + + key.replace('{{ option }}', String(option)), + userLanguage, + } as never + } + > + {children} + + ); const hook = renderHook(() => useChannelPreviewPollLabel({ pollId: POLL_ID }), { wrapper }); return { ...hook, poll }; @@ -62,7 +75,7 @@ describe('useChannelPreviewPollLabel', () => { it('returns no label once the only vote is removed', () => { const vote = generateVote('vote-a', 'option-a', '2026-10-27T10:00:00.000Z'); const { poll, result } = setup([vote]); - expect(result.current).toBe('You voted: {{ option }}'); + expect(result.current).toBe('You voted: A'); act(() => { poll.removeVote(vote.id, MESSAGE_ID); @@ -81,6 +94,18 @@ describe('useChannelPreviewPollLabel', () => { }); expect(poll.data.latest_votes_by_option['option-a']).toBeUndefined(); - expect(result.current).toBe('You voted: {{ option }}'); + expect(result.current).toBe('You voted: B'); + }); + + it("labels the vote with the option's translation into the user's language", () => { + const vote = generateVote('vote-a', 'option-a', '2026-10-27T10:00:00.000Z'); + const { result } = setup([vote], 'es'); + expect(result.current).toBe('You voted: A (es)'); + }); + + it('labels the vote with the original option text when it has no translation', () => { + const vote = generateVote('vote-b', 'option-b', '2026-10-27T10:00:00.000Z'); + const { result } = setup([vote], 'es'); + expect(result.current).toBe('You voted: B'); }); }); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts index 733e93bf0a..c896d6b3dc 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts @@ -5,6 +5,7 @@ import { PollState } from 'stream-chat'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { useStateStore } from '../../../hooks/useStateStore'; +import { getTranslatedText } from '../../../utils/i18n/getTranslatedText'; export type UseChannelPreviewPollLabelProps = { pollId: string; @@ -22,7 +23,7 @@ const selector = (nextValue: PollState) => ({ */ export const useChannelPreviewPollLabel = ({ pollId }: UseChannelPreviewPollLabelProps) => { const { client } = useChatContext(); - const { t } = useTranslationContext(); + const { t, userLanguage } = useTranslationContext(); const poll = client.polls.fromState(pollId); const pollState = useStateStore(poll?.state, selector) ?? ({} as PollState); const { latest_votes_by_option: latestVotesByOption, options: pollOptions } = pollState; @@ -42,14 +43,17 @@ export const useChannelPreviewPollLabel = ({ pollId }: UseChannelPreviewPollLabe { const option = pollOptions?.find((option) => option.id === latestVotes[0].option_id); const voteUser = latestVotes[0].user; + const optionText = option + ? (getTranslatedText(option.text_i18n, userLanguage) ?? option.text) + : undefined; if (voteUser.id === client.user?.id) { - return t('You voted: {{ option }}', { option: option?.text }); + return t('You voted: {{ option }}', { option: optionText }); } else { return t('{{ user }} voted: {{ option }}', { user: voteUser.name || voteUser.id, - option: option?.text, + option: optionText, }); } } - }, [latestVotes, pollOptions, client.user?.id, t]); + }, [latestVotes, pollOptions, client.user?.id, t, userLanguage]); }; diff --git a/package/src/components/Poll/Poll.tsx b/package/src/components/Poll/Poll.tsx index ca5fc14fcc..eeb2622860 100644 --- a/package/src/components/Poll/Poll.tsx +++ b/package/src/components/Poll/Poll.tsx @@ -8,6 +8,7 @@ import { PollUIStateProvider } from './contexts/PollUIStateContext'; import { useIsPollCreatedByCurrentUser } from './hook/useIsPollCreatedByCurrentUser'; import { usePollState } from './hooks/usePollState'; +import { usePollTextTranslation } from './hooks/usePollTextTranslation'; import { PollContextProvider, @@ -27,7 +28,8 @@ export type PollContentProps = Record; export const PollHeader = () => { const styles = useStyles(); const { t } = useTranslationContext(); - const { enforceUniqueVote, isClosed, maxVotesAllowed, name } = usePollState(); + const { enforceUniqueVote, isClosed, maxVotesAllowed, name, nameI18n } = usePollState(); + const translate = usePollTextTranslation(); const subtitle = useMemo(() => { if (isClosed) { @@ -52,7 +54,7 @@ export const PollHeader = () => { return ( - {name} + {translate(name, nameI18n)} {subtitle} ); diff --git a/package/src/components/Poll/__tests__/PollTranslation.test.tsx b/package/src/components/Poll/__tests__/PollTranslation.test.tsx new file mode 100644 index 0000000000..60719ae26f --- /dev/null +++ b/package/src/components/Poll/__tests__/PollTranslation.test.tsx @@ -0,0 +1,152 @@ +import React from 'react'; + +import { act, render, screen } from '@testing-library/react-native'; +import dayjs from 'dayjs'; +import { Poll, PollAnswer, PollResponse, StreamChat, TranslationLanguages } from 'stream-chat'; + +import { + ChatProvider, + OwnCapabilitiesProvider, + PollContextProvider, + ThemeProvider, + TranslationProvider, +} from '../../../contexts'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { PollAnswerListItem } from '../components/PollAnswersList'; +import { PollAllOptionsContent, PollOption } from '../components/PollOption'; +import { PollResultsContent } from '../components/PollResults/PollResults'; +import { PollHeader } from '../Poll'; + +const POLL_ID = 'poll-id'; + +const pollResponse = { + created_by: { id: 'other-user' }, + enforce_unique_vote: true, + id: POLL_ID, + latest_answers: [], + latest_votes_by_option: {}, + name: 'Favourite colour?', + name_i18n: { es_text: '¿Color favorito?', language: 'en' }, + options: [ + { id: 'option-red', text: 'Red', text_i18n: { es_text: 'Rojo', language: 'en' } }, + { id: 'option-blue', text: 'Blue' }, + ], + vote_count: 0, + vote_counts_by_option: {}, +} as unknown as PollResponse; + +const answer = { + answer_text: 'Green, actually', + answer_text_i18n: { es_text: 'Verde, en realidad', language: 'en' }, + created_at: '2026-10-27T10:00:00.000Z', + id: 'answer-id', + is_answer: true, + poll_id: POLL_ID, + updated_at: '2026-10-27T10:00:00.000Z', + user: { id: 'other-user', name: 'Other' }, +} as unknown as PollAnswer; + +const setup = (userLanguage: TranslationLanguages) => { + const client = { user: { id: 'own-user' }, userID: 'own-user' } as unknown as StreamChat; + const poll = new Poll({ client, poll: pollResponse }); + const [red, blue] = poll.data.options; + + render( + + + key, tDateTimeParser: dayjs, userLanguage } as never} + > + + + + + + + + + + + , + ); + + return { poll }; +}; + +const renderDialogs = (userLanguage: TranslationLanguages) => { + const client = { user: { id: 'own-user' }, userID: 'own-user' } as unknown as StreamChat; + const poll = new Poll({ client, poll: pollResponse }); + + return render( + + + key, userLanguage } as never}> + + + + + + + + + , + ); +}; + +describe('Poll translations', () => { + it("renders the question, options and answers in the user's language", () => { + setup('es'); + + expect(screen.getByText('¿Color favorito?')).toBeTruthy(); + expect(screen.getByText('Rojo')).toBeTruthy(); + expect(screen.getByLabelText('Rojo')).toBeTruthy(); + expect(screen.getByText('Verde, en realidad')).toBeTruthy(); + expect(screen.queryByText('Favourite colour?')).toBeNull(); + expect(screen.queryByText('Red')).toBeNull(); + }); + + it('falls back to the original text when a text has no translation', () => { + setup('es'); + + expect(screen.getByText('Blue')).toBeTruthy(); + expect(screen.getByLabelText('Blue')).toBeTruthy(); + }); + + it("renders the original texts when nothing is translated into the user's language", () => { + setup('de'); + + expect(screen.getByText('Favourite colour?')).toBeTruthy(); + expect(screen.getByText('Red')).toBeTruthy(); + expect(screen.getByText('Blue')).toBeTruthy(); + expect(screen.getByText('Green, actually')).toBeTruthy(); + }); + + it('shows a translation that arrives later through poll.updated', () => { + const { poll } = setup('de'); + expect(screen.getByText('Favourite colour?')).toBeTruthy(); + + act(() => { + poll.handlePollUpdated({ + created_at: '2026-10-27T11:00:00.000Z', + poll: { + ...pollResponse, + name_i18n: { de_text: 'Lieblingsfarbe?', language: 'en' }, + }, + type: 'poll.updated', + } as never); + }); + + expect(screen.getByText('Lieblingsfarbe?')).toBeTruthy(); + expect(screen.queryByText('Favourite colour?')).toBeNull(); + }); + + it('translates the question and options in the results and all-options dialogs', () => { + renderDialogs('es'); + + // Each dialog shows the question once and every option once. + expect(screen.getAllByText('¿Color favorito?')).toHaveLength(2); + expect(screen.getAllByText('Rojo')).toHaveLength(2); + expect(screen.getAllByText('Blue')).toHaveLength(2); + expect(screen.queryByText('Favourite colour?')).toBeNull(); + }); +}); diff --git a/package/src/components/Poll/components/PollAnswersList.tsx b/package/src/components/Poll/components/PollAnswersList.tsx index b522be7281..d3bcba4b21 100644 --- a/package/src/components/Poll/components/PollAnswersList.tsx +++ b/package/src/components/Poll/components/PollAnswersList.tsx @@ -21,6 +21,7 @@ import { Button } from '../../ui'; import { UserAvatar } from '../../ui/Avatar/UserAvatar'; import { usePollAnswersPagination } from '../hooks/usePollAnswersPagination'; import { usePollState } from '../hooks/usePollState'; +import { usePollTextTranslation } from '../hooks/usePollTextTranslation'; export const AnswerListAddCommentButton = (props: PollButtonProps) => { const { t } = useTranslationContext(); @@ -71,6 +72,7 @@ export const PollAnswerListItem = ({ answer }: { answer: PollAnswer }) => { const { client } = useChatContext(); const { t, tDateTimeParser } = useTranslationContext(); const { votingVisibility } = usePollState(); + const translate = usePollTextTranslation(); const { theme: { @@ -104,7 +106,9 @@ export const PollAnswerListItem = ({ answer }: { answer: PollAnswer }) => { return ( - {answer.answer_text} + + {translate(answer.answer_text, answer.answer_text_i18n)} + {!isAnonymous && answer.user?.image ? ( diff --git a/package/src/components/Poll/components/PollOption.tsx b/package/src/components/Poll/components/PollOption.tsx index 8cadf6d92d..4ba7a6bd03 100644 --- a/package/src/components/Poll/components/PollOption.tsx +++ b/package/src/components/Poll/components/PollOption.tsx @@ -23,6 +23,7 @@ import { ProgressBar } from '../../ProgressControl/ProgressBar'; import { UserAvatarStack } from '../../ui/Avatar/AvatarStack'; import { useIsPollCreatedByCurrentUser } from '../hook/useIsPollCreatedByCurrentUser'; import { usePollState } from '../hooks/usePollState'; +import { usePollTextTranslation } from '../hooks/usePollTextTranslation'; import { usePollVoteToggle } from '../hooks/usePollVoteToggle'; const pollVoteAccessibilityStates = { @@ -44,7 +45,8 @@ export const PollAllOptionsContent = ({ additionalScrollViewProps, }: Pick) => { const { t } = useTranslationContext(); - const { name, options } = usePollState(); + const { name, nameI18n, options } = usePollState(); + const translate = usePollTextTranslation(); const { theme: { @@ -63,7 +65,7 @@ export const PollAllOptionsContent = ({ > {t('Question')} - {name} + {translate(name, nameI18n)} {options?.map((option: PollOptionClass) => ( @@ -92,6 +94,7 @@ export const PollAllOptions = ({ export const PollOption = ({ option, showProgressBar = true, forceIncoming }: PollOptionProps) => { const { latestVotesByOption, voteCountsByOption, voteCount } = usePollState(); const { t } = useTranslationContext(); + const translate = usePollTextTranslation(); const isPollCreatedByClient = useIsPollCreatedByCurrentUser(); // `forceIncoming` is set on the full-options surface, which is a neutral card // rather than a bubble - so it wins over poll ownership. @@ -129,7 +132,7 @@ export const PollOption = ({ option, showProgressBar = true, forceIncoming }: Po - {option.text} + {translate(option.text, option.text_i18n)} {relevantVotes.length > 0 ? ( @@ -186,6 +189,7 @@ export const VoteButton = ({ forceIncoming, onPress, option }: PollVoteButtonPro } = useTheme(); const toggleVote = usePollVoteToggle(); + const translate = usePollTextTranslation(); const onPressHandler = useCallback(() => { if (onPress) { @@ -203,7 +207,7 @@ export const VoteButton = ({ forceIncoming, onPress, option }: PollVoteButtonPro return ownCapabilities.castPollVote && !isClosed ? ( ( - {option.text} + {optionText} {t('{{count}} votes', { count: voteCountsByOption[option.id] ?? 0 })} @@ -57,7 +60,7 @@ export const PollOptionFullResultsContent = ({ headerText, headerTitle, option.id, - option.text, + optionText, styles.headerContainer, styles.headerText, styles.headerTitle, diff --git a/package/src/components/Poll/components/PollResults/PollResultItem.tsx b/package/src/components/Poll/components/PollResults/PollResultItem.tsx index ddddfd2dbb..02f577778e 100644 --- a/package/src/components/Poll/components/PollResults/PollResultItem.tsx +++ b/package/src/components/Poll/components/PollResults/PollResultItem.tsx @@ -15,6 +15,7 @@ import { import { primitives } from '../../../../theme'; import { usePollState } from '../../hooks/usePollState'; +import { usePollTextTranslation } from '../../hooks/usePollTextTranslation'; import { GenericPollButton } from '../Button'; import { PollModal } from '../PollModal'; import { PollModalHeader } from '../PollModalHeader'; @@ -86,6 +87,7 @@ const PollResultsVoteItem = (vote: PollVoteClass) => ( export const PollResultsItem = ({ option, index }: PollResultItemProps) => { const { t } = useTranslationContext(); const { latestVotesByOption, voteCountsByOption } = usePollState(); + const translate = usePollTextTranslation(); const { theme: { @@ -105,7 +107,7 @@ export const PollResultsItem = ({ option, index }: PollResultItemProps) => { {t('Option {{count}}', { count: index + 1 })} - {option.text} + {translate(option.text, option.text_i18n)} {t('{{count}} votes', { count: voteCountsByOption[option.id] ?? 0 })} diff --git a/package/src/components/Poll/components/PollResults/PollResults.tsx b/package/src/components/Poll/components/PollResults/PollResults.tsx index 019d67e945..e0314b1701 100644 --- a/package/src/components/Poll/components/PollResults/PollResults.tsx +++ b/package/src/components/Poll/components/PollResults/PollResults.tsx @@ -14,6 +14,7 @@ import { import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; import { primitives } from '../../../../theme'; import { usePollState } from '../../hooks/usePollState'; +import { usePollTextTranslation } from '../../hooks/usePollTextTranslation'; export type PollResultsProps = PollContextValue & { additionalScrollViewProps?: Partial; @@ -22,7 +23,8 @@ export type PollResultsProps = PollContextValue & { export const PollResultsContent = ({ additionalScrollViewProps, }: Pick) => { - const { name, options, voteCountsByOption } = usePollState(); + const { name, nameI18n, options, voteCountsByOption } = usePollState(); + const translate = usePollTextTranslation(); const sortedOptions = useMemo( () => @@ -51,7 +53,7 @@ export const PollResultsContent = ({ > {t('Question')} - {name} + {translate(name, nameI18n)} {sortedOptions.map((option, index) => ( diff --git a/package/src/components/Poll/hooks/usePollState.ts b/package/src/components/Poll/hooks/usePollState.ts index 26fd646569..8ef46a522c 100644 --- a/package/src/components/Poll/hooks/usePollState.ts +++ b/package/src/components/Poll/hooks/usePollState.ts @@ -7,6 +7,7 @@ import { PollOption, PollState, PollVote, + TranslatedField, UpdatePollAPIResponse, UserResponse, VotingVisibility, @@ -28,6 +29,7 @@ export type UsePollStateSelectorReturnType = { maxVotedOptionIds: string[]; maxVotesAllowed: number; name: string; + nameI18n: TranslatedField | undefined; options: PollOption[]; ownAnswer: PollAnswer | undefined; ownVotesByOptionId: Record; @@ -53,6 +55,7 @@ const selector = (nextValue: PollState): UsePollStateSelectorReturnType => ({ maxVotedOptionIds: nextValue.maxVotedOptionIds, maxVotesAllowed: nextValue.max_votes_allowed, name: nextValue.name, + nameI18n: nextValue.name_i18n, options: nextValue.options, ownAnswer: nextValue.ownAnswer, ownVotesByOptionId: nextValue.ownVotesByOptionId, @@ -74,6 +77,7 @@ export const usePollState = (): UsePollStateReturnType => { maxVotedOptionIds, maxVotesAllowed, name, + nameI18n, options, ownAnswer, ownVotesByOptionId, @@ -108,6 +112,7 @@ export const usePollState = (): UsePollStateReturnType => { maxVotedOptionIds, maxVotesAllowed, name, + nameI18n, options, ownAnswer, ownVotesByOptionId, diff --git a/package/src/components/Poll/hooks/usePollTextTranslation.ts b/package/src/components/Poll/hooks/usePollTextTranslation.ts new file mode 100644 index 0000000000..ac2c2c2f4a --- /dev/null +++ b/package/src/components/Poll/hooks/usePollTextTranslation.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react'; + +import type { TranslatedField } from 'stream-chat'; + +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { getTranslatedText } from '../../../utils/i18n/getTranslatedText'; + +/** + * Returns `translate(original, i18n)`, which resolves a poll text (question, option or answer) + * into the current user's language. Falls back to `original` when there is no translation for it, + * the same way auto-translated message text does. + */ +export const usePollTextTranslation = () => { + const { userLanguage } = useTranslationContext(); + + return useCallback( + (original: string, i18n: TranslatedField | undefined): string => + getTranslatedText(i18n, userLanguage) ?? original, + [userLanguage], + ); +}; diff --git a/package/src/components/Poll/index.ts b/package/src/components/Poll/index.ts index ed6e2af135..8ed1d09d07 100644 --- a/package/src/components/Poll/index.ts +++ b/package/src/components/Poll/index.ts @@ -8,3 +8,4 @@ export * from './hooks/usePollState'; export * from './hooks/usePollStateStore'; export * from './hooks/usePollAnswersPagination'; export * from './hooks/usePollOptionVotesPagination'; +export * from './hooks/usePollTextTranslation'; diff --git a/package/src/hooks/__tests__/useTranslatedMessage.test.tsx b/package/src/hooks/__tests__/useTranslatedMessage.test.tsx index 80fd2bc91c..7e60c4cf6b 100644 --- a/package/src/hooks/__tests__/useTranslatedMessage.test.tsx +++ b/package/src/hooks/__tests__/useTranslatedMessage.test.tsx @@ -59,6 +59,26 @@ describe('useTranslatedMessage', () => { }); }); + it("doesn't translate a deleted message", async () => { + const message = { + i18n: { + no_text: 'Hallo verden!', + }, + text: 'Hello world!', + type: 'deleted', + } as unknown as MessageResponse; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText('Hello world!')).toBeTruthy(); + }); + }); + it("returns the original text if the message doesn't contain any translations", async () => { const message = { text: 'Hello world!', diff --git a/package/src/hooks/messagePreview/__tests__/useMessagePreviewText.test.tsx b/package/src/hooks/messagePreview/__tests__/useMessagePreviewText.test.tsx index 406b272f53..bd2ac08138 100644 --- a/package/src/hooks/messagePreview/__tests__/useMessagePreviewText.test.tsx +++ b/package/src/hooks/messagePreview/__tests__/useMessagePreviewText.test.tsx @@ -2,7 +2,7 @@ import React, { PropsWithChildren } from 'react'; import { renderHook, waitFor } from '@testing-library/react-native'; -import type { Attachment } from 'stream-chat'; +import type { Attachment, PollResponse, StreamChat } from 'stream-chat'; import { Chat } from '../../../components/Chat/Chat'; import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; @@ -15,8 +15,12 @@ import { generateMessage } from '../../../mock-builders/generator/message'; import { useMessagePreviewText } from '../useMessagePreviewText'; describe('useMessagePreviewText', () => { - const render = async (message: ReturnType) => { + const render = async ( + message: ReturnType, + prepareClient?: (client: StreamChat) => void, + ) => { const { client } = await initiateClientWithChannels(); + prepareClient?.(client); const wrapper = ({ children }: PropsWithChildren) => {children}; const { result } = renderHook(() => useMessagePreviewText({ message }), { wrapper }); return result; @@ -56,4 +60,35 @@ describe('useMessagePreviewText', () => { ); expect(result.current).toBe('hello'); }); + + describe('poll messages', () => { + const poll = { + id: 'poll-id', + latest_answers: [], + latest_votes_by_option: {}, + name: 'Favourite colour?', + name_i18n: { es_text: '¿Color favorito?', language: 'en' }, + options: [], + vote_count: 0, + vote_counts_by_option: {}, + } as unknown as PollResponse; + + const renderPollMessage = (language: string) => { + const message = generateMessage({ poll, poll_id: poll.id, text: '' }); + return render(message, (client) => { + (client.user as { language?: string }).language = language; + client.polls.hydratePollCache([message]); + }); + }; + + it("shows the poll question in the user's language", async () => { + const result = await renderPollMessage('es'); + expect(result.current).toBe('¿Color favorito?'); + }); + + it('shows the original poll question when it has no translation', async () => { + const result = await renderPollMessage('de'); + expect(result.current).toBe('Favourite colour?'); + }); + }); }); diff --git a/package/src/hooks/messagePreview/useMessagePreviewText.tsx b/package/src/hooks/messagePreview/useMessagePreviewText.tsx index 8e96d4a99d..2e2f5d1e13 100644 --- a/package/src/hooks/messagePreview/useMessagePreviewText.tsx +++ b/package/src/hooks/messagePreview/useMessagePreviewText.tsx @@ -12,9 +12,11 @@ import { useGroupedAttachments } from './useGroupedAttachments'; import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { useStateStore } from '../../hooks/useStateStore'; +import { getTranslatedText } from '../../utils/i18n/getTranslatedText'; const selector = (nextValue: PollState) => ({ name: nextValue.name, + name_i18n: nextValue.name_i18n, }); export const useMessagePreviewText = ({ @@ -22,10 +24,11 @@ export const useMessagePreviewText = ({ }: { message?: LocalMessage | MessageResponse | DraftMessage | null; }) => { - const { t } = useTranslationContext(); + const { t, userLanguage } = useTranslationContext(); const { client } = useChatContext(); const poll = client.polls.fromState(message?.poll_id ?? ''); - const { name: pollName } = useStateStore(poll?.state, selector) ?? {}; + const { name, name_i18n } = useStateStore(poll?.state, selector) ?? {}; + const pollName = getTranslatedText(name_i18n, userLanguage) ?? name; const { giphys, audios, images, videos, files, voiceRecordings } = useGroupedAttachments( message?.attachments, ); diff --git a/package/src/hooks/useTranslatedMessage.ts b/package/src/hooks/useTranslatedMessage.ts index 6cbe9d9229..058f8580df 100644 --- a/package/src/hooks/useTranslatedMessage.ts +++ b/package/src/hooks/useTranslatedMessage.ts @@ -1,22 +1,22 @@ -import type { LocalMessage, MessageResponse, TranslationLanguages } from 'stream-chat'; +import type { LocalMessage, MessageResponse } from 'stream-chat'; import { useTranslationContext } from '../contexts/translationContext/TranslationContext'; - -type TranslationKey = `${TranslationLanguages}_text`; +import { getTranslatedText } from '../utils/i18n/getTranslatedText'; export const useTranslatedMessage = (message?: LocalMessage | MessageResponse) => { const { userLanguage } = useTranslationContext(); - const translationKey: TranslationKey = `${userLanguage}_text`; - if (!message) { return undefined; } - if (message.i18n && translationKey in message.i18n && message.type !== 'deleted') { + const translatedText = + message.type !== 'deleted' ? getTranslatedText(message.i18n, userLanguage) : undefined; + + if (translatedText !== undefined) { return { ...message, - text: message.i18n[translationKey], + text: translatedText, }; } diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index b8b0c28f6f..2332ae5055 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -51,7 +51,7 @@ export class SqliteClientError extends Error { * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. */ export class SqliteClient { - static dbVersion = 16; + static dbVersion = 17; static dbName = DB_NAME; static dbLocation = DB_LOCATION; diff --git a/package/src/store/apis/__tests__/pollTranslations.test.ts b/package/src/store/apis/__tests__/pollTranslations.test.ts new file mode 100644 index 0000000000..04bea93d9f --- /dev/null +++ b/package/src/store/apis/__tests__/pollTranslations.test.ts @@ -0,0 +1,136 @@ +import type { ChannelAPIResponse, PollResponse } from 'stream-chat'; + +import { generateChannelResponse } from '../../../mock-builders/generator/channel'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { BetterSqlite } from '../../../test-utils/BetterSqlite'; +import { SqliteClient } from '../../SqliteClient'; +import { getChannels } from '../getChannels'; +import { upsertChannels } from '../upsertChannels'; +import { upsertPoll } from '../upsertPoll'; + +const POLL_ID = 'poll-id'; + +const translatedPoll = { + answers_count: 0, + created_at: '2026-10-27T10:00:00.000Z', + created_by: { id: 'other-user' }, + created_by_id: 'other-user', + description: 'For the team dinner', + description_i18n: { es_text: 'Para la cena del equipo', language: 'en' }, + enforce_unique_vote: true, + id: POLL_ID, + latest_answers: [], + latest_votes_by_option: {}, + max_votes_allowed: 1, + name: 'Favourite colour?', + name_i18n: { es_text: '¿Color favorito?', language: 'en' }, + options: [ + { + id: 'option-red', + poll_id: POLL_ID, + text: 'Red', + text_i18n: { es_text: 'Rojo', language: 'en' }, + }, + ], + updated_at: '2026-10-27T10:00:00.000Z', + vote_count: 0, + vote_counts_by_option: {}, +} as unknown as PollResponse; + +/** + * The backend translates poll texts. The offline DB has to keep those translations, so a cold start + * offline shows the poll in the user's language, the same way message translations are kept. + */ +describe('poll translations in the offline DB', () => { + let cid: string; + + beforeEach(async () => { + await SqliteClient.initializeDatabase(); + await BetterSqlite.openDB(); + + const channelResponse = generateChannelResponse({ members: [], messages: [] }); + cid = channelResponse.channel.cid as string; + channelResponse.messages = [ + generateMessage({ cid, poll: translatedPoll, poll_id: POLL_ID, text: '' }), + ] as unknown as typeof channelResponse.messages; + await upsertChannels({ + channels: [channelResponse] as unknown as ChannelAPIResponse[], + isLatestMessagesSet: true, + }); + }); + + afterEach(() => { + BetterSqlite.dropAllTables(); + BetterSqlite.closeDB(); + }); + + const getStoredPoll = async () => { + const [channel] = await getChannels({ channelIds: [cid], currentUserId: 'some-user' }); + return channel.messages[0].poll; + }; + + it("keeps the question's, description's and options' translations", async () => { + const poll = await getStoredPoll(); + + expect(poll?.name_i18n).toEqual(translatedPoll.name_i18n); + expect(poll?.description_i18n).toEqual(translatedPoll.description_i18n); + expect(poll?.options[0].text_i18n).toEqual(translatedPoll.options[0].text_i18n); + }); + + it('replaces the translations when the poll is stored again', async () => { + const name_i18n = { de_text: 'Lieblingsfarbe?', language: 'en' }; + await upsertPoll({ poll: { ...translatedPoll, name_i18n } as PollResponse }); + + expect((await getStoredPoll())?.name_i18n).toEqual(name_i18n); + }); + + it('stores a poll without translations unchanged', async () => { + const { + description_i18n: _description_i18n, + name_i18n: _name_i18n, + ...untranslatedPoll + } = translatedPoll; + const untranslatedChannel = generateChannelResponse({ members: [], messages: [] }); + const untranslatedCid = untranslatedChannel.channel.cid as string; + untranslatedChannel.messages = [ + generateMessage({ + cid: untranslatedCid, + poll: { + ...untranslatedPoll, + id: 'untranslated-poll-id', + options: [{ id: 'option-red', poll_id: 'untranslated-poll-id', text: 'Red' }], + } as unknown as PollResponse, + poll_id: 'untranslated-poll-id', + text: '', + }), + ] as unknown as typeof untranslatedChannel.messages; + await upsertChannels({ + channels: [untranslatedChannel] as unknown as ChannelAPIResponse[], + isLatestMessagesSet: true, + }); + + const [channel] = await getChannels({ + channelIds: [untranslatedCid], + currentUserId: 'some-user', + }); + const poll = channel.messages[0].poll; + + expect(poll?.name).toBe('Favourite colour?'); + expect(poll?.description).toBe('For the team dinner'); + expect(poll?.options).toEqual([ + { id: 'option-red', poll_id: 'untranslated-poll-id', text: 'Red' }, + ]); + expect(poll?.name_i18n).toBeUndefined(); + expect(poll?.description_i18n).toBeUndefined(); + }); + + it('clears translations that the poll no longer has', async () => { + await upsertPoll({ + poll: { ...translatedPoll, description_i18n: undefined, name_i18n: undefined }, + }); + + const poll = await getStoredPoll(); + expect(poll?.name_i18n).toBeUndefined(); + expect(poll?.description_i18n).toBeUndefined(); + }); +}); diff --git a/package/src/store/mappers/mapPollToStorable.ts b/package/src/store/mappers/mapPollToStorable.ts index fb9ea75bd5..701be14f23 100644 --- a/package/src/store/mappers/mapPollToStorable.ts +++ b/package/src/store/mappers/mapPollToStorable.ts @@ -13,6 +13,7 @@ export const mapPollToStorable = (poll: PollResponse): TableRow<'poll'> => { created_by, created_by_id, description, + description_i18n, enforce_unique_vote, id, is_closed, @@ -20,6 +21,7 @@ export const mapPollToStorable = (poll: PollResponse): TableRow<'poll'> => { latest_votes_by_option, max_votes_allowed, name, + name_i18n, options, own_votes, updated_at, @@ -36,6 +38,9 @@ export const mapPollToStorable = (poll: PollResponse): TableRow<'poll'> => { created_by: JSON.stringify(created_by), // decouple the users from the actual poll created_by_id, description, + // Always written (as `null` when absent): an upsert skips undefined columns, which would keep + // translations the server has since dropped, e.g. after the question was edited. + description_i18n: JSON.stringify(description_i18n ?? null), enforce_unique_vote, id, is_closed, @@ -43,6 +48,7 @@ export const mapPollToStorable = (poll: PollResponse): TableRow<'poll'> => { latest_votes_by_option: JSON.stringify(latest_votes_by_option), max_votes_allowed, name, + name_i18n: JSON.stringify(name_i18n ?? null), options: JSON.stringify(options), own_votes: JSON.stringify(own_votes), updated_at, diff --git a/package/src/store/mappers/mapStorableToPoll.ts b/package/src/store/mappers/mapStorableToPoll.ts index 63a8eb7fc0..6ea4ae2d10 100644 --- a/package/src/store/mappers/mapStorableToPoll.ts +++ b/package/src/store/mappers/mapStorableToPoll.ts @@ -11,6 +11,7 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponse => { created_by, created_by_id, description, + description_i18n, enforce_unique_vote, id, is_closed, @@ -18,6 +19,7 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponse => { latest_votes_by_option, max_votes_allowed, name, + name_i18n, options, own_votes, updated_at, @@ -34,6 +36,7 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponse => { created_by: JSON.parse(created_by), created_by_id, description, + description_i18n: description_i18n ? (JSON.parse(description_i18n) ?? undefined) : undefined, enforce_unique_vote, id, is_closed, @@ -41,6 +44,7 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponse => { latest_votes_by_option: JSON.parse(latest_votes_by_option), max_votes_allowed, name, + name_i18n: name_i18n ? (JSON.parse(name_i18n) ?? undefined) : undefined, options: JSON.parse(options), own_votes: own_votes ? JSON.parse(own_votes) : [], updated_at, diff --git a/package/src/store/schema.ts b/package/src/store/schema.ts index 9b918e44f5..fa2476a4ee 100644 --- a/package/src/store/schema.ts +++ b/package/src/store/schema.ts @@ -224,6 +224,7 @@ export const tables: Tables = { created_by: 'TEXT', created_by_id: 'TEXT', description: 'TEXT', + description_i18n: 'TEXT', enforce_unique_vote: 'BOOLEAN DEFAULT FALSE', id: 'TEXT NOT NULL', is_closed: 'BOOLEAN DEFAULT FALSE', @@ -231,6 +232,7 @@ export const tables: Tables = { latest_votes_by_option: 'TEXT', max_votes_allowed: 'INTEGER DEFAULT 1', name: 'TEXT', + name_i18n: 'TEXT', options: 'TEXT', own_votes: 'TEXT', updated_at: 'TEXT', @@ -459,7 +461,9 @@ export type Schema = { allow_answers?: boolean; allow_user_suggested_options?: boolean; description?: string; + description_i18n?: string; is_closed?: boolean; + name_i18n?: string; own_votes?: string; voting_visibility?: string; }; diff --git a/package/src/utils/i18n/__tests__/getTranslatedText.test.ts b/package/src/utils/i18n/__tests__/getTranslatedText.test.ts new file mode 100644 index 0000000000..14eaecdfe8 --- /dev/null +++ b/package/src/utils/i18n/__tests__/getTranslatedText.test.ts @@ -0,0 +1,19 @@ +import type { TranslatedField } from 'stream-chat'; + +import { getTranslatedText } from '../getTranslatedText'; + +describe('getTranslatedText', () => { + const i18n = { de_text: 'Wie geht es?', language: 'en' } as TranslatedField; + + it('returns the translation into the given language', () => { + expect(getTranslatedText(i18n, 'de')).toBe('Wie geht es?'); + }); + + it('returns undefined when there is no translation into the given language', () => { + expect(getTranslatedText(i18n, 'fr')).toBeUndefined(); + }); + + it('returns undefined when the text has no translations', () => { + expect(getTranslatedText(undefined, 'de')).toBeUndefined(); + }); +}); diff --git a/package/src/utils/i18n/getTranslatedText.ts b/package/src/utils/i18n/getTranslatedText.ts new file mode 100644 index 0000000000..f143bc12fc --- /dev/null +++ b/package/src/utils/i18n/getTranslatedText.ts @@ -0,0 +1,9 @@ +import type { TranslatedField, TranslationLanguages } from 'stream-chat'; + +/** + * Returns the translation of a text into `language`, or undefined if there is none. + */ +export const getTranslatedText = ( + i18n: TranslatedField | undefined, + language: TranslationLanguages, +): string | undefined => i18n?.[`${language}_text` as `${TranslationLanguages}_text`];