Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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' };
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -52,7 +53,19 @@ const setup = (ownVotes: PollVote[]) => {
(client as unknown as { polls: unknown }).polls = { fromState: () => poll };

const wrapper = ({ children }: PropsWithChildren) => (
<ChatProvider value={{ client } as never}>{children}</ChatProvider>
<ChatProvider value={{ client } as never}>
<TranslationProvider
value={
{
t: (key: string, { option }: { option?: string } = {}) =>
key.replace('{{ option }}', String(option)),
userLanguage,
} as never
}
>
{children}
</TranslationProvider>
</ChatProvider>
);
const hook = renderHook(() => useChannelPreviewPollLabel({ pollId: POLL_ID }), { wrapper });
return { ...hook, poll };
Expand All @@ -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);
Expand All @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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]);
};
6 changes: 4 additions & 2 deletions package/src/components/Poll/Poll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,7 +28,8 @@ export type PollContentProps = Record<string, never>;
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) {
Expand All @@ -52,7 +54,7 @@ export const PollHeader = () => {

return (
<View accessible accessibilityRole='text' style={styles.headerContainer}>
<Text style={[styles.headerTitle, header.title]}>{name}</Text>
<Text style={[styles.headerTitle, header.title]}>{translate(name, nameI18n)}</Text>
<Text style={[styles.headerSubtitle, header.subtitle]}>{subtitle}</Text>
</View>
);
Expand Down
152 changes: 152 additions & 0 deletions package/src/components/Poll/__tests__/PollTranslation.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ThemeProvider>
<ChatProvider value={{ client } as never}>
<TranslationProvider
value={{ t: (key: string) => key, tDateTimeParser: dayjs, userLanguage } as never}
>
<OwnCapabilitiesProvider value={{ castPollVote: true } as never}>
<PollContextProvider value={{ message: generateMessage(), poll }}>
<PollHeader />
<PollOption option={red} />
<PollOption option={blue} />
<PollAnswerListItem answer={answer} />
</PollContextProvider>
</OwnCapabilitiesProvider>
</TranslationProvider>
</ChatProvider>
</ThemeProvider>,
);

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(
<ThemeProvider>
<ChatProvider value={{ client } as never}>
<TranslationProvider value={{ t: (key: string) => key, userLanguage } as never}>
<OwnCapabilitiesProvider value={{} as never}>
<PollContextProvider value={{ message: generateMessage(), poll }}>
<PollResultsContent />
<PollAllOptionsContent />
</PollContextProvider>
</OwnCapabilitiesProvider>
</TranslationProvider>
</ChatProvider>
</ThemeProvider>,
);
};

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();
});
});
6 changes: 5 additions & 1 deletion package/src/components/Poll/components/PollAnswersList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -104,7 +106,9 @@ export const PollAnswerListItem = ({ answer }: { answer: PollAnswer }) => {
return (
<View style={[styles.listItemWrapper, itemStyle.wrapper]}>
<View style={[styles.listItemContainer, itemStyle.container]}>
<Text style={[styles.listItemAnswerText, itemStyle.answerText]}>{answer.answer_text}</Text>
<Text style={[styles.listItemAnswerText, itemStyle.answerText]}>
{translate(answer.answer_text, answer.answer_text_i18n)}
</Text>
<View style={[styles.listItemInfoContainer, itemStyle.infoContainer]}>
<View style={[styles.listItemUserInfoContainer, itemStyle.userInfoContainer]}>
{!isAnonymous && answer.user?.image ? (
Expand Down
12 changes: 8 additions & 4 deletions package/src/components/Poll/components/PollOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -44,7 +45,8 @@ export const PollAllOptionsContent = ({
additionalScrollViewProps,
}: Pick<PollAllOptionsContentProps, 'additionalScrollViewProps'>) => {
const { t } = useTranslationContext();
const { name, options } = usePollState();
const { name, nameI18n, options } = usePollState();
const translate = usePollTextTranslation();

const {
theme: {
Expand All @@ -63,7 +65,7 @@ export const PollAllOptionsContent = ({
>
<View style={[styles.allOptionsTitleContainer, titleContainer]}>
<Text style={styles.allOptionsTitleMeta}>{t('Question')}</Text>
<Text style={[styles.allOptionsTitleText, titleText]}>{name}</Text>
<Text style={[styles.allOptionsTitleText, titleText]}>{translate(name, nameI18n)}</Text>
</View>
<View style={[styles.allOptionsListContainer, listContainer]}>
{options?.map((option: PollOptionClass) => (
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -129,7 +132,7 @@ export const PollOption = ({ option, showProgressBar = true, forceIncoming }: Po
<View style={[styles.info, info]}>
<View style={[styles.header, header]}>
<Text ellipsizeMode='tail' numberOfLines={2} style={[styles.text, text]}>
{option.text}
{translate(option.text, option.text_i18n)}
</Text>
<View style={[styles.votesContainer, votesContainer]}>
{relevantVotes.length > 0 ? (
Expand Down Expand Up @@ -186,6 +189,7 @@ export const VoteButton = ({ forceIncoming, onPress, option }: PollVoteButtonPro
} = useTheme();

const toggleVote = usePollVoteToggle();
const translate = usePollTextTranslation();

const onPressHandler = useCallback(() => {
if (onPress) {
Expand All @@ -203,7 +207,7 @@ export const VoteButton = ({ forceIncoming, onPress, option }: PollVoteButtonPro

return ownCapabilities.castPollVote && !isClosed ? (
<Pressable
accessibilityLabel={option.text}
accessibilityLabel={translate(option.text, option.text_i18n)}
accessibilityRole={poll.data?.enforce_unique_vote ? 'radio' : 'checkbox'}
accessibilityState={accessibilityState}
onPress={onPressHandler}
Expand Down
Loading
Loading