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`];