diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx index ce29f28020..4ce983a7d0 100644 --- a/package/src/components/Message/MessageItemView/MessageContent.tsx +++ b/package/src/components/Message/MessageItemView/MessageContent.tsx @@ -3,6 +3,7 @@ import { ColorValue, Pressable, StyleSheet, View, ViewStyle } from 'react-native import { MessageTextContainer } from './MessageTextContainer'; +import { useA11yLabel } from '../../../a11y/hooks/useA11yLabel'; import { useChatContext } from '../../../contexts'; import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; import { @@ -127,6 +128,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { hidePaddingBottom, } = props; const { client } = useChatContext(); + const accessibilityHint = useA11yLabel('a11y/Double tap and hold to activate contextual menu'); const { Attachment, FileAttachmentGroup, @@ -318,6 +320,8 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { return ( { if (onLongPress) { diff --git a/package/src/components/Poll/Poll.tsx b/package/src/components/Poll/Poll.tsx index e717d55739..8c839cfa81 100644 --- a/package/src/components/Poll/Poll.tsx +++ b/package/src/components/Poll/Poll.tsx @@ -4,15 +4,20 @@ import { StyleSheet, Text, View } from 'react-native'; import { PollOption as PollOptionClass } from 'stream-chat'; import { PollOption, ShowAllOptionsButton } from './components'; +import { PollUIStateProvider } from './contexts/PollUIStateContext'; +import { usePollAccessibilityActions } from './hooks/usePollAccessibilityActions'; +import { usePollAccessibilityLabel } from './hooks/usePollAccessibilityLabel'; import { usePollState } from './hooks/usePollState'; +import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; import { PollContextProvider, PollContextValue, useTheme, useTranslationContext, } from '../../contexts'; +import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { primitives } from '../../theme'; @@ -61,6 +66,10 @@ export const PollContent = () => { const styles = useStyles(); const { PollButtons: PollButtonsComponent, PollHeader: PollHeaderComponent } = useComponentsContext(); + const { enabled: a11yEnabled } = useAccessibilityContext(); + const accessibilityHint = useA11yLabel('a11y/Double tap and hold to activate contextual menu'); + const accessibilityLabel = usePollAccessibilityLabel(); + const { accessibilityActions, onAccessibilityAction } = usePollAccessibilityActions(); const { theme: { @@ -70,8 +79,24 @@ export const PollContent = () => { }, } = useTheme(); + // NOTE: Android custom accessibilityActions are broken in RN < 0.83.2 — + // see facebook/react-native#47268, fixed by PR #52724. On affected versions + // the actions menu surfaces only a subset of the list and dispatch + // announces "Action not supported". iOS works correctly on all versions. + // Once the SDK's minimum RN reaches 0.83.2, wrap the descendants below in + // so Android + // TalkBack groups them under the composite rather than exposing each + // interactive child as a separate focus stop. return ( - + {options?.slice(0, defaultPollOptionCount)?.map((option: PollOptionClass) => ( @@ -93,7 +118,9 @@ export const Poll = ({ message, poll }: PollProps) => { poll, }} > - {PollContentOverride ? : } + + {PollContentOverride ? : } + ); }; diff --git a/package/src/components/Poll/components/PollButtons.tsx b/package/src/components/Poll/components/PollButtons.tsx index b8cc97b737..2fbb4298f1 100644 --- a/package/src/components/Poll/components/PollButtons.tsx +++ b/package/src/components/Poll/components/PollButtons.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Modal, StyleSheet, View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -13,13 +13,22 @@ import { useChatContext, usePollContext, useTheme, useTranslationContext } from import { primitives } from '../../../theme'; import { defaultPollOptionCount } from '../../../utils/constants'; import { SafeAreaViewWrapper } from '../../UIComponents/SafeAreaViewWrapper'; +import { + useAddCommentOpen, + useAllCommentsOpen, + useAllOptionsOpen, + usePollUIStateContext, + useSuggestOptionOpen, + useViewResultsOpen, +} from '../contexts/PollUIStateContext'; import { useIsPollCreatedByCurrentUser } from '../hook/useIsPollCreatedByCurrentUser'; import { usePollState } from '../hooks/usePollState'; export const ViewResultsButton = (props: PollButtonProps) => { const { t } = useTranslationContext(); const { message, poll } = usePollContext(); - const [showResults, setShowResults] = useState(false); + const { closeViewResults, openViewResults } = usePollUIStateContext(); + const showResults = useViewResultsOpen(); const { onPress } = props; const onPressHandler = useCallback(() => { @@ -28,15 +37,11 @@ export const ViewResultsButton = (props: PollButtonProps) => { return; } - setShowResults(true); - }, [message, onPress, poll]); + openViewResults(); + }, [message, onPress, openViewResults, poll]); const styles = useStyles(); - const onRequestClose = useCallback(() => { - setShowResults(false); - }, []); - return ( <> { type='outline' /> {showResults ? ( - + - + @@ -61,7 +66,8 @@ export const ViewResultsButton = (props: PollButtonProps) => { export const ShowAllOptionsButton = (props: PollButtonProps) => { const { t } = useTranslationContext(); - const [showAllOptions, setShowAllOptions] = useState(false); + const { closeAllOptions, openAllOptions } = usePollUIStateContext(); + const showAllOptions = useAllOptionsOpen(); const { message, poll } = usePollContext(); const { options } = usePollState(); const { onPress } = props; @@ -72,12 +78,8 @@ export const ShowAllOptionsButton = (props: PollButtonProps) => { return; } - setShowAllOptions(true); - }, [message, onPress, poll]); - - const onRequestClose = useCallback(() => { - setShowAllOptions(false); - }, []); + openAllOptions(); + }, [message, onPress, openAllOptions, poll]); const styles = useStyles(); @@ -90,10 +92,10 @@ export const ShowAllOptionsButton = (props: PollButtonProps) => { /> ) : null} {showAllOptions ? ( - + - + @@ -107,7 +109,8 @@ export const ShowAllCommentsButton = (props: PollButtonProps) => { const { t } = useTranslationContext(); const { message, poll } = usePollContext(); const { answersCount } = usePollState(); - const [showAnswers, setShowAnswers] = useState(false); + const { closeAllComments, openAllComments } = usePollUIStateContext(); + const showAnswers = useAllCommentsOpen(); const { onPress } = props; const onPressHandler = useCallback(() => { @@ -116,15 +119,11 @@ export const ShowAllCommentsButton = (props: PollButtonProps) => { return; } - setShowAnswers(true); - }, [message, onPress, poll]); + openAllComments(); + }, [message, onPress, openAllComments, poll]); const styles = useStyles(); - const onRequestClose = useCallback(() => { - setShowAnswers(false); - }, []); - return ( <> {answersCount && answersCount > 0 ? ( @@ -134,10 +133,10 @@ export const ShowAllCommentsButton = (props: PollButtonProps) => { /> ) : null} {showAnswers ? ( - + - + @@ -151,7 +150,8 @@ export const SuggestOptionButton = (props: PollButtonProps) => { const { t } = useTranslationContext(); const { message, poll } = usePollContext(); const { addOption, allowUserSuggestedOptions, isClosed } = usePollState(); - const [showAddOptionDialog, setShowAddOptionDialog] = useState(false); + const { closeSuggestOption, openSuggestOption } = usePollUIStateContext(); + const showAddOptionDialog = useSuggestOptionOpen(); const { onPress } = props; const onPressHandler = useCallback(() => { @@ -160,12 +160,8 @@ export const SuggestOptionButton = (props: PollButtonProps) => { return; } - setShowAddOptionDialog(true); - }, [message, onPress, poll]); - - const onRequestClose = useCallback(() => { - setShowAddOptionDialog(false); - }, []); + openSuggestOption(); + }, [message, onPress, openSuggestOption, poll]); return ( <> @@ -174,7 +170,7 @@ export const SuggestOptionButton = (props: PollButtonProps) => { ) : null} {showAddOptionDialog ? ( { const { t } = useTranslationContext(); const { message, poll } = usePollContext(); const { addComment, allowAnswers, isClosed, ownAnswer } = usePollState(); - const [showAddCommentDialog, setShowAddCommentDialog] = useState(false); + const { closeAddComment, openAddComment } = usePollUIStateContext(); + const showAddCommentDialog = useAddCommentOpen(); const { onPress } = props; const onPressHandler = useCallback(() => { @@ -198,12 +195,8 @@ export const AddCommentButton = (props: PollButtonProps) => { return; } - setShowAddCommentDialog(true); - }, [message, onPress, poll]); - - const onRequestClose = useCallback(() => { - setShowAddCommentDialog(false); - }, []); + openAddComment(); + }, [message, onPress, openAddComment, poll]); return ( <> @@ -212,7 +205,7 @@ export const AddCommentButton = (props: PollButtonProps) => { ) : null} {showAddCommentDialog ? ( { const { message, poll } = usePollContext(); const { isClosed, ownVotesByOptionId } = usePollState(); - const { runWithNotificationTarget } = useNotificationApi(); const ownCapabilities = useOwnCapabilitiesContext(); const { theme: { semantics }, @@ -179,15 +178,7 @@ export const VoteButton = ({ onPress, option }: PollVoteButtonProps) => { }, } = useTheme(); - const toggleVote = useCallback(async () => { - await runWithNotificationTarget(async () => { - if (ownVotesByOptionId[option.id]) { - await poll.removeVote(ownVotesByOptionId[option.id]?.id, message.id); - } else { - await poll.castVote(option.id, message.id); - } - }); - }, [message.id, option.id, ownVotesByOptionId, poll, runWithNotificationTarget]); + const toggleVote = usePollVoteToggle(); const onPressHandler = useCallback(() => { if (onPress) { @@ -195,8 +186,8 @@ export const VoteButton = ({ onPress, option }: PollVoteButtonProps) => { return; } - toggleVote(); - }, [message, onPress, poll, toggleVote]); + toggleVote(option.id); + }, [message, onPress, option.id, poll, toggleVote]); const hasVote = !!ownVotesByOptionId[option.id]; const accessibilityState = hasVote diff --git a/package/src/components/Poll/contexts/PollUIStateContext.tsx b/package/src/components/Poll/contexts/PollUIStateContext.tsx new file mode 100644 index 0000000000..d0d44bd4b4 --- /dev/null +++ b/package/src/components/Poll/contexts/PollUIStateContext.tsx @@ -0,0 +1,105 @@ +import React, { PropsWithChildren, useContext, useState } from 'react'; + +import { StateStore } from 'stream-chat'; + +import { DEFAULT_BASE_CONTEXT_VALUE } from '../../../contexts/utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../../../contexts/utils/isTestEnvironment'; +import { useStateStore } from '../../../hooks/useStateStore'; + +export type PollUIState = { + addCommentOpen: boolean; + allCommentsOpen: boolean; + allOptionsOpen: boolean; + suggestOptionOpen: boolean; + viewResultsOpen: boolean; +}; + +const INITIAL_POLL_UI_STATE: PollUIState = { + addCommentOpen: false, + allCommentsOpen: false, + allOptionsOpen: false, + suggestOptionOpen: false, + viewResultsOpen: false, +}; + +export type PollUIStateContextValue = { + closeAddComment: () => void; + closeAllComments: () => void; + closeAllOptions: () => void; + closeSuggestOption: () => void; + closeViewResults: () => void; + openAddComment: () => void; + openAllComments: () => void; + openAllOptions: () => void; + openSuggestOption: () => void; + openViewResults: () => void; + store: StateStore; +}; + +export const PollUIStateContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as PollUIStateContextValue, +); + +export const PollUIStateProvider = ({ children }: PropsWithChildren) => { + const value = useState(() => { + const store = new StateStore(INITIAL_POLL_UI_STATE); + return { + closeAddComment: () => store.partialNext({ addCommentOpen: false }), + closeAllComments: () => store.partialNext({ allCommentsOpen: false }), + closeAllOptions: () => store.partialNext({ allOptionsOpen: false }), + closeSuggestOption: () => store.partialNext({ suggestOptionOpen: false }), + closeViewResults: () => store.partialNext({ viewResultsOpen: false }), + openAddComment: () => store.partialNext({ addCommentOpen: true }), + openAllComments: () => store.partialNext({ allCommentsOpen: true }), + openAllOptions: () => store.partialNext({ allOptionsOpen: true }), + openSuggestOption: () => store.partialNext({ suggestOptionOpen: true }), + openViewResults: () => store.partialNext({ viewResultsOpen: true }), + store, + }; + })[0]; + + return {children}; +}; + +export const usePollUIStateContext = () => { + const contextValue = useContext(PollUIStateContext) as unknown as PollUIStateContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'usePollUIStateContext must be used within a PollUIStateProvider. The provider is mounted by the Poll component automatically.', + ); + } + + return contextValue; +}; + +const selectAddCommentOpen = ({ addCommentOpen }: PollUIState) => ({ addCommentOpen }); +const selectAllCommentsOpen = ({ allCommentsOpen }: PollUIState) => ({ allCommentsOpen }); +const selectAllOptionsOpen = ({ allOptionsOpen }: PollUIState) => ({ allOptionsOpen }); +const selectSuggestOptionOpen = ({ suggestOptionOpen }: PollUIState) => ({ suggestOptionOpen }); +const selectViewResultsOpen = ({ viewResultsOpen }: PollUIState) => ({ viewResultsOpen }); + +export const useAddCommentOpen = () => { + const { store } = usePollUIStateContext(); + return useStateStore(store, selectAddCommentOpen).addCommentOpen; +}; + +export const useAllCommentsOpen = () => { + const { store } = usePollUIStateContext(); + return useStateStore(store, selectAllCommentsOpen).allCommentsOpen; +}; + +export const useAllOptionsOpen = () => { + const { store } = usePollUIStateContext(); + return useStateStore(store, selectAllOptionsOpen).allOptionsOpen; +}; + +export const useSuggestOptionOpen = () => { + const { store } = usePollUIStateContext(); + return useStateStore(store, selectSuggestOptionOpen).suggestOptionOpen; +}; + +export const useViewResultsOpen = () => { + const { store } = usePollUIStateContext(); + return useStateStore(store, selectViewResultsOpen).viewResultsOpen; +}; diff --git a/package/src/components/Poll/contexts/index.ts b/package/src/components/Poll/contexts/index.ts new file mode 100644 index 0000000000..ba317f3f6e --- /dev/null +++ b/package/src/components/Poll/contexts/index.ts @@ -0,0 +1 @@ +export * from './PollUIStateContext'; diff --git a/package/src/components/Poll/hooks/__tests__/usePollAccessibilityActions.test.tsx b/package/src/components/Poll/hooks/__tests__/usePollAccessibilityActions.test.tsx new file mode 100644 index 0000000000..fabff2cf88 --- /dev/null +++ b/package/src/components/Poll/hooks/__tests__/usePollAccessibilityActions.test.tsx @@ -0,0 +1,358 @@ +import React from 'react'; + +import type { AccessibilityActionEvent } from 'react-native'; + +import { act, renderHook } from '@testing-library/react-native'; + +import { AccessibilityProvider } from '../../../../contexts/accessibilityContext/AccessibilityContext'; +import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; +import { usePollAccessibilityActions } from '../usePollAccessibilityActions'; + +const mockOpenAddComment = jest.fn(); +const mockOpenAllComments = jest.fn(); +const mockOpenAllOptions = jest.fn(); +const mockOpenSuggestOption = jest.fn(); +const mockOpenViewResults = jest.fn(); +const mockEndVote = jest.fn(); +const mockToggleVote = jest.fn(); + +jest.mock('../../contexts/PollUIStateContext', () => ({ + usePollUIStateContext: () => ({ + openAddComment: mockOpenAddComment, + openAllComments: mockOpenAllComments, + openAllOptions: mockOpenAllOptions, + openSuggestOption: mockOpenSuggestOption, + openViewResults: mockOpenViewResults, + }), +})); + +jest.mock('../usePollStateStore', () => ({ + usePollStateStore: (selector: (state: unknown) => unknown) => selector(mockPollState), +})); + +jest.mock('../useEndVote', () => ({ + useEndVote: () => mockEndVote, +})); + +jest.mock('../usePollVoteToggle', () => ({ + usePollVoteToggle: () => mockToggleVote, +})); + +const mockChatContext = { client: { userID: 'me' } }; +const mockOwnCapabilities = { castPollVote: true }; + +jest.mock('../../../../contexts', () => { + const actual = jest.requireActual('../../../../contexts'); + return { + ...actual, + useChatContext: () => mockChatContext, + useOwnCapabilitiesContext: () => mockOwnCapabilities, + }; +}); + +let mockPollState: Record = {}; + +const setPollState = (state: Record) => { + mockPollState = state; +}; + +const setCastPollVote = (allowed: boolean) => { + mockOwnCapabilities.castPollVote = allowed; +}; + +const setUserID = (id: string) => { + mockChatContext.client.userID = id; +}; + +const t = (key: string, vars?: Record) => { + if (!vars) return key; + if (key === 'a11y/Vote on {{option}}') return `Vote on ${vars.option}`; + return key; +}; + +const wrapper = + (enabled: boolean) => + ({ children }: { children: React.ReactNode }) => ( + + null, + } as never + } + > + {children} + + + ); + +const buildOption = (id: string, text: string) => ({ id, text }); + +const fireAction = ( + handler: ((event: AccessibilityActionEvent) => void) | undefined, + actionName: string, +) => { + handler?.({ nativeEvent: { actionName } } as AccessibilityActionEvent); +}; + +beforeEach(() => { + mockOpenAddComment.mockClear(); + mockOpenAllComments.mockClear(); + mockOpenAllOptions.mockClear(); + mockOpenSuggestOption.mockClear(); + mockOpenViewResults.mockClear(); + mockEndVote.mockClear(); + mockToggleVote.mockClear(); + setCastPollVote(true); + setUserID('me'); +}); + +describe('usePollAccessibilityActions', () => { + it('returns undefined when accessibility is disabled', () => { + setPollState({ + allow_answers: true, + allow_user_suggested_options: true, + created_by: { id: 'me' }, + is_closed: false, + options: [buildOption('o1', 'A')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(false), + }); + + expect(result.current.accessibilityActions).toBeUndefined(); + expect(result.current.onAccessibilityAction).toBeUndefined(); + }); + + it('every action uses the same human label for name and label', () => { + setPollState({ + allow_answers: true, + allow_user_suggested_options: true, + created_by: { id: 'me' }, + is_closed: false, + options: [buildOption('o1', 'Pizza'), buildOption('o2', 'Pasta')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + const actions = result.current.accessibilityActions; + expect(actions).toBeDefined(); + for (const action of actions ?? []) { + expect(action.name).toBe(action.label); + } + }); + + it('exposes only View Results for an ended poll', () => { + setPollState({ + allow_answers: true, + allow_user_suggested_options: true, + created_by: { id: 'me' }, + is_closed: true, + options: [buildOption('o1', 'A'), buildOption('o2', 'B')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + const labels = result.current.accessibilityActions?.map((a) => a.label); + expect(labels).toEqual(['View Results']); + }); + + it('lists vote actions with the option text, plus End vote / Add comment / Suggest option for creator', () => { + setPollState({ + allow_answers: true, + allow_user_suggested_options: true, + created_by: { id: 'me' }, + is_closed: false, + options: [buildOption('o1', 'Pizza'), buildOption('o2', 'Pasta')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + const labels = result.current.accessibilityActions?.map((a) => a.label); + expect(labels).toEqual([ + 'View Results', + 'Vote on Pizza', + 'Vote on Pasta', + 'a11y/End vote', + 'Add a comment', + 'Suggest an option', + ]); + }); + + it('omits End vote when the current user is not the creator', () => { + setUserID('someone-else'); + setPollState({ + allow_answers: false, + allow_user_suggested_options: false, + created_by: { id: 'me' }, + is_closed: false, + options: [buildOption('o1', 'Pizza')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + const labels = result.current.accessibilityActions?.map((a) => a.label); + expect(labels).toEqual(['View Results', 'Vote on Pizza']); + }); + + it('omits vote actions when the user lacks castPollVote capability', () => { + setCastPollVote(false); + setPollState({ + allow_answers: true, + allow_user_suggested_options: false, + created_by: { id: 'somebody' }, + is_closed: false, + options: [buildOption('o1', 'Pizza')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + const labels = result.current.accessibilityActions?.map((a) => a.label); + expect(labels?.some((l) => l?.startsWith('Vote on'))).toBe(false); + }); + + it('exposes "View N comments" when the poll has answers', () => { + setPollState({ + allow_answers: false, + allow_user_suggested_options: false, + answers_count: 4, + created_by: { id: 'somebody' }, + is_closed: true, + options: [buildOption('o1', 'A')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + const labels = result.current.accessibilityActions?.map((a) => a.label); + expect(labels).toContain('View {{count}} comments'); + }); + + it('omits "View N comments" when there are no answers', () => { + setPollState({ + allow_answers: false, + allow_user_suggested_options: false, + answers_count: 0, + created_by: { id: 'somebody' }, + is_closed: true, + options: [buildOption('o1', 'A')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + const labels = result.current.accessibilityActions?.map((a) => a.label); + expect(labels?.some((l) => l?.includes('comments'))).toBe(false); + }); + + it('exposes Show all options when options exceed the visible cap', () => { + const manyOptions = Array.from({ length: 12 }, (_, i) => buildOption(`o${i}`, `Option ${i}`)); + setPollState({ + allow_answers: false, + allow_user_suggested_options: false, + created_by: { id: 'somebody' }, + is_closed: true, + options: manyOptions, + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + const labels = result.current.accessibilityActions?.map((a) => a.label); + expect(labels).toContain('a11y/Show all options'); + }); + + it('routes each action to the right side effect', () => { + setPollState({ + allow_answers: true, + allow_user_suggested_options: true, + created_by: { id: 'me' }, + is_closed: false, + options: [buildOption('o1', 'Pizza'), buildOption('o2', 'Pasta')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + act(() => { + fireAction(result.current.onAccessibilityAction, 'View Results'); + }); + expect(mockOpenViewResults).toHaveBeenCalledTimes(1); + + act(() => { + fireAction(result.current.onAccessibilityAction, 'a11y/End vote'); + }); + expect(mockEndVote).toHaveBeenCalledTimes(1); + + act(() => { + fireAction(result.current.onAccessibilityAction, 'Add a comment'); + }); + expect(mockOpenAddComment).toHaveBeenCalledTimes(1); + + act(() => { + fireAction(result.current.onAccessibilityAction, 'Suggest an option'); + }); + expect(mockOpenSuggestOption).toHaveBeenCalledTimes(1); + + act(() => { + fireAction(result.current.onAccessibilityAction, 'Vote on Pasta'); + }); + expect(mockToggleVote).toHaveBeenCalledWith('o2'); + }); + + it('routes the "View N comments" action to openAllComments', () => { + setPollState({ + allow_answers: false, + allow_user_suggested_options: false, + answers_count: 7, + created_by: { id: 'somebody' }, + is_closed: true, + options: [buildOption('o1', 'A')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + act(() => { + fireAction(result.current.onAccessibilityAction, 'View {{count}} comments'); + }); + expect(mockOpenAllComments).toHaveBeenCalledTimes(1); + }); + + it('ignores unknown action names', () => { + setPollState({ + allow_answers: true, + allow_user_suggested_options: true, + created_by: { id: 'me' }, + is_closed: false, + options: [buildOption('o1', 'Pizza')], + }); + + const { result } = renderHook(() => usePollAccessibilityActions(), { + wrapper: wrapper(true), + }); + + act(() => { + fireAction(result.current.onAccessibilityAction, 'streamPollVoteOption_o1'); + }); + expect(mockToggleVote).not.toHaveBeenCalled(); + expect(mockOpenViewResults).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/Poll/hooks/__tests__/usePollAccessibilityLabel.test.tsx b/package/src/components/Poll/hooks/__tests__/usePollAccessibilityLabel.test.tsx new file mode 100644 index 0000000000..3a0ec50c2a --- /dev/null +++ b/package/src/components/Poll/hooks/__tests__/usePollAccessibilityLabel.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; + +import { renderHook } from '@testing-library/react-native'; + +import { AccessibilityProvider } from '../../../../contexts/accessibilityContext/AccessibilityContext'; +import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; +import { usePollAccessibilityLabel } from '../usePollAccessibilityLabel'; + +jest.mock('../usePollStateStore', () => ({ + usePollStateStore: (selector: (state: unknown) => unknown) => selector(mockPollState), +})); + +let mockPollState: Record = {}; + +const setPollState = (state: Record) => { + mockPollState = state; +}; + +const t = (key: string, vars?: Record) => { + if (!vars) return key; + if (key === '{{count}} votes') return `${vars.count} votes`; + if (key === 'Select up to {{count}}') return `Select up to ${vars.count}`; + if (key === '+{{count}} More Options') return `+${vars.count} More Options`; + return key; +}; + +const wrapper = + (enabled: boolean) => + ({ children }: { children: React.ReactNode }) => ( + + null, + } as never + } + > + {children} + + + ); + +const buildOption = (id: string, text: string) => ({ id, text }); + +describe('usePollAccessibilityLabel', () => { + it('returns undefined when accessibility is disabled', () => { + setPollState({ + enforce_unique_vote: false, + is_closed: true, + max_votes_allowed: 0, + name: 'Lunch?', + options: [buildOption('o1', 'Pizza')], + vote_counts_by_option: { o1: 3 }, + }); + + const { result } = renderHook(() => usePollAccessibilityLabel(), { + wrapper: wrapper(false), + }); + + expect(result.current).toBeUndefined(); + }); + + it('builds composite label for an ended poll', () => { + setPollState({ + enforce_unique_vote: false, + is_closed: true, + max_votes_allowed: 0, + name: 'Test', + options: [buildOption('o1', 'Option 1'), buildOption('o2', 'Option 2')], + vote_counts_by_option: { o1: 0, o2: 0 }, + }); + + const { result } = renderHook(() => usePollAccessibilityLabel(), { + wrapper: wrapper(true), + }); + + expect(result.current).toBe( + 'Test, Poll has ended, Option 1: 0 votes, Option 2: 0 votes, a11y/Activate to view results', + ); + }); + + it('uses "Select one" for an open enforce-unique-vote poll', () => { + setPollState({ + enforce_unique_vote: true, + is_closed: false, + max_votes_allowed: 0, + name: 'Pick a venue', + options: [buildOption('o1', 'Cafe')], + vote_counts_by_option: { o1: 2 }, + }); + + const { result } = renderHook(() => usePollAccessibilityLabel(), { + wrapper: wrapper(true), + }); + + expect(result.current).toBe( + 'Pick a venue, Select one, Cafe: 2 votes, a11y/Activate to view results', + ); + }); + + it('uses "Select up to N" when maxVotesAllowed is set', () => { + setPollState({ + enforce_unique_vote: false, + is_closed: false, + max_votes_allowed: 3, + name: 'Top picks', + options: [buildOption('o1', 'A')], + vote_counts_by_option: { o1: 1 }, + }); + + const { result } = renderHook(() => usePollAccessibilityLabel(), { + wrapper: wrapper(true), + }); + + expect(result.current).toBe( + 'Top picks, Select up to 3, A: 1 votes, a11y/Activate to view results', + ); + }); + + it('appends overflow hint when options exceed the visible cap', () => { + const manyOptions = Array.from({ length: 12 }, (_, i) => buildOption(`o${i}`, `Option ${i}`)); + const counts = Object.fromEntries(manyOptions.map((o) => [o.id, 0])); + + setPollState({ + enforce_unique_vote: false, + is_closed: false, + max_votes_allowed: 0, + name: 'Big poll', + options: manyOptions, + vote_counts_by_option: counts, + }); + + const { result } = renderHook(() => usePollAccessibilityLabel(), { + wrapper: wrapper(true), + }); + + expect(result.current).toContain('+7 More Options'); + expect(result.current).toContain('Option 0: 0 votes'); + expect(result.current).not.toContain('Option 5:'); + }); +}); diff --git a/package/src/components/Poll/hooks/useEndVote.ts b/package/src/components/Poll/hooks/useEndVote.ts new file mode 100644 index 0000000000..f498a6b9e6 --- /dev/null +++ b/package/src/components/Poll/hooks/useEndVote.ts @@ -0,0 +1,37 @@ +import { usePollContext, useTranslationContext } from '../../../contexts'; +import { useStableCallback } from '../../../hooks'; +import { useNotificationApi } from '../../Notifications'; + +/** + * Returns a stable callback that closes the current poll and emits a success or + * failure notification. Shared by `EndVoteButton` and the rotor accessibility + * action so both paths produce identical side effects. + */ +export const useEndVote = () => { + const { poll } = usePollContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + + return useStableCallback(async () => { + try { + const response = await poll.close(); + addNotification({ + message: t('Poll ended'), + options: { severity: 'success', type: 'api:poll:end:success' }, + origin: { emitter: 'PollActions' }, + }); + return response; + } catch (error) { + addNotification({ + message: t('Failed to end the poll'), + options: { + ...(error instanceof Error ? { originalError: error } : {}), + severity: 'error', + type: 'api:poll:end:failed', + }, + origin: { emitter: 'PollActions' }, + }); + throw error; + } + }); +}; diff --git a/package/src/components/Poll/hooks/usePollAccessibilityActions.ts b/package/src/components/Poll/hooks/usePollAccessibilityActions.ts new file mode 100644 index 0000000000..86d85735d7 --- /dev/null +++ b/package/src/components/Poll/hooks/usePollAccessibilityActions.ts @@ -0,0 +1,191 @@ +import { useMemo } from 'react'; + +import type { AccessibilityActionEvent, AccessibilityProps } from 'react-native'; + +import { PollOption, PollState, UserResponse } from 'stream-chat'; + +import { useEndVote } from './useEndVote'; + +import { usePollStateStore } from './usePollStateStore'; + +import { usePollVoteToggle } from './usePollVoteToggle'; + +import { + useChatContext, + useOwnCapabilitiesContext, + useTranslationContext, +} from '../../../contexts'; +import { useAccessibilityContext } from '../../../contexts/accessibilityContext/AccessibilityContext'; +import { useStableCallback } from '../../../hooks'; +import { defaultPollOptionCount } from '../../../utils/constants'; +import { usePollUIStateContext } from '../contexts/PollUIStateContext'; + +type AccessibilityAction = NonNullable[number]; +type OnAccessibilityAction = NonNullable; + +type PollA11yActionsSelectorResult = { + allowAnswers: boolean | undefined; + allowUserSuggestedOptions: boolean | undefined; + answersCount: number; + createdBy: UserResponse | null; + isClosed: boolean | undefined; + options: PollOption[]; +}; + +const a11yActionsSelector = (state: PollState): PollA11yActionsSelectorResult => ({ + allowAnswers: state.allow_answers, + allowUserSuggestedOptions: state.allow_user_suggested_options, + answersCount: state.answers_count, + createdBy: state.created_by, + isClosed: state.is_closed, + options: state.options, +}); + +export type UsePollAccessibilityActionsResult = { + accessibilityActions: readonly AccessibilityAction[] | undefined; + onAccessibilityAction: OnAccessibilityAction | undefined; +}; + +type ActionKind = + | { type: 'addComment' } + | { type: 'endVote' } + | { type: 'showAllComments' } + | { type: 'showAllOptions' } + | { type: 'suggestOption' } + | { type: 'viewResults' } + | { type: 'vote'; optionId: string }; + +/** + * Returns the `accessibilityActions` array and `onAccessibilityAction` handler + * for the poll composite container. Action set is gated by poll state + + * capabilities so each rotor entry corresponds to an interaction the user is + * actually allowed to perform. Returns `undefined`s when a11y is disabled. + * + * NOTE: We set both `name` and `label` to the same human-readable string on + * every action. iOS Fabric (new architecture, on by default in RN 0.81+) uses + * `accessibilityAction.name` as the string VoiceOver reads — `label` is + * ignored on that path (RCTViewComponentView.mm). iOS legacy (Paper) and + * Android both read `label`. Using the same value for both fields means the + * announcement is human-readable on every platform/architecture. Dispatch + * uses the action name as the lookup key into an internal kind map, so the + * raw strings never need to be exposed to consumers. + */ +export const usePollAccessibilityActions = (): UsePollAccessibilityActionsResult => { + const { enabled } = useAccessibilityContext(); + const { t } = useTranslationContext(); + const { client } = useChatContext(); + const { castPollVote } = useOwnCapabilitiesContext(); + const { allowAnswers, allowUserSuggestedOptions, answersCount, createdBy, isClosed, options } = + usePollStateStore(a11yActionsSelector); + const { openAddComment, openAllComments, openAllOptions, openSuggestOption, openViewResults } = + usePollUIStateContext(); + const toggleVote = usePollVoteToggle(); + const endVote = useEndVote(); + + const canVote = !isClosed && !!castPollVote; + const canEnd = !isClosed && createdBy?.id === client.userID; + const canComment = !isClosed && !!allowAnswers; + const canSuggest = !isClosed && !!allowUserSuggestedOptions; + const hasMoreOptions = !!options && options.length > defaultPollOptionCount; + const hasComments = answersCount > 0; + + const { accessibilityActions, actionKindByName } = useMemo<{ + accessibilityActions: readonly AccessibilityAction[] | undefined; + actionKindByName: Map | undefined; + }>(() => { + if (!enabled) { + return { accessibilityActions: undefined, actionKindByName: undefined }; + } + + const actions: AccessibilityAction[] = []; + const kindByName = new Map(); + + const push = (name: string, kind: ActionKind) => { + actions.push({ label: name, name }); + kindByName.set(name, kind); + }; + + push(t('View Results'), { type: 'viewResults' }); + + if (canVote && options) { + for (const option of options.slice(0, defaultPollOptionCount)) { + push(t('a11y/Vote on {{option}}', { option: option.text }), { + optionId: option.id, + type: 'vote', + }); + } + } + + if (hasMoreOptions) { + push(t('a11y/Show all options'), { type: 'showAllOptions' }); + } + + if (canEnd) { + push(t('a11y/End vote'), { type: 'endVote' }); + } + + if (canComment) { + push(t('Add a comment'), { type: 'addComment' }); + } + + if (canSuggest) { + push(t('Suggest an option'), { type: 'suggestOption' }); + } + + if (hasComments) { + push(t('View {{count}} comments', { count: answersCount }), { type: 'showAllComments' }); + } + + return { accessibilityActions: actions, actionKindByName: kindByName }; + }, [ + answersCount, + canComment, + canEnd, + canSuggest, + canVote, + enabled, + hasComments, + hasMoreOptions, + options, + t, + ]); + + const onAccessibilityAction = useStableCallback((event: AccessibilityActionEvent) => { + const kind = actionKindByName?.get(event.nativeEvent.actionName); + if (!kind) return; + + switch (kind.type) { + case 'viewResults': + openViewResults(); + return; + case 'showAllOptions': + openAllOptions(); + return; + case 'endVote': + void endVote(); + return; + case 'addComment': + openAddComment(); + return; + case 'suggestOption': + openSuggestOption(); + return; + case 'showAllComments': + openAllComments(); + return; + case 'vote': + void toggleVote(kind.optionId); + return; + default: + return; + } + }); + + return useMemo( + () => ({ + accessibilityActions, + onAccessibilityAction: enabled ? onAccessibilityAction : undefined, + }), + [accessibilityActions, enabled, onAccessibilityAction], + ); +}; diff --git a/package/src/components/Poll/hooks/usePollAccessibilityLabel.ts b/package/src/components/Poll/hooks/usePollAccessibilityLabel.ts new file mode 100644 index 0000000000..5617ae8fd0 --- /dev/null +++ b/package/src/components/Poll/hooks/usePollAccessibilityLabel.ts @@ -0,0 +1,75 @@ +import { useMemo } from 'react'; + +import { PollOption, PollState } from 'stream-chat'; + +import { usePollStateStore } from './usePollStateStore'; + +import { composeAccessibilityLabel } from '../../../a11y/a11yUtils'; +import { useTranslationContext } from '../../../contexts'; +import { useAccessibilityContext } from '../../../contexts/accessibilityContext/AccessibilityContext'; +import { defaultPollOptionCount } from '../../../utils/constants'; + +type PollA11yLabelSelectorResult = { + enforceUniqueVote: boolean; + isClosed: boolean | undefined; + maxVotesAllowed: number; + name: string; + options: PollOption[]; + voteCountsByOption: Record; +}; + +const a11yLabelSelector = (state: PollState): PollA11yLabelSelectorResult => ({ + enforceUniqueVote: state.enforce_unique_vote, + isClosed: state.is_closed, + maxVotesAllowed: state.max_votes_allowed, + name: state.name, + options: state.options, + voteCountsByOption: state.vote_counts_by_option, +}); + +/** + * Builds the composite accessibility label for a poll bubble: name, status, + * up to `defaultPollOptionCount` options with vote counts, an overflow hint, + * and the primary-tap hint. Returns `undefined` when a11y is disabled so the + * Poll container can leave its `accessibilityLabel` unset. + */ +export const usePollAccessibilityLabel = (): string | undefined => { + const { enabled } = useAccessibilityContext(); + const { t } = useTranslationContext(); + const { enforceUniqueVote, isClosed, maxVotesAllowed, name, options, voteCountsByOption } = + usePollStateStore(a11yLabelSelector); + + return useMemo(() => { + if (!enabled) return undefined; + + let status: string; + if (isClosed) { + status = t('Poll has ended'); + } else if (enforceUniqueVote) { + status = t('Select one'); + } else if (maxVotesAllowed) { + status = t('Select up to {{count}}', { count: maxVotesAllowed }); + } else { + status = t('Select one or more'); + } + + const visibleOptions = options?.slice(0, defaultPollOptionCount) ?? []; + const optionParts = visibleOptions.map((option) => { + const count = voteCountsByOption?.[option.id] ?? 0; + return `${option.text}: ${t('{{count}} votes', { count })}`; + }); + + const overflow = + options && options.length > defaultPollOptionCount + ? t('+{{count}} More Options', { count: options.length - defaultPollOptionCount }) + : null; + + return composeAccessibilityLabel( + name, + status, + ...optionParts, + overflow, + t('a11y/Activate to view results'), + ); + }, [enabled, enforceUniqueVote, isClosed, maxVotesAllowed, name, options, t, voteCountsByOption]); +}; diff --git a/package/src/components/Poll/hooks/usePollState.ts b/package/src/components/Poll/hooks/usePollState.ts index 8ed432bdee..78d7b2ff8c 100644 --- a/package/src/components/Poll/hooks/usePollState.ts +++ b/package/src/components/Poll/hooks/usePollState.ts @@ -12,10 +12,10 @@ import { VotingVisibility, } from 'stream-chat'; +import { useEndVote } from './useEndVote'; import { usePollStateStore } from './usePollStateStore'; -import { usePollContext, useTranslationContext } from '../../../contexts'; -import { useNotificationApi } from '../../Notifications'; +import { usePollContext } from '../../../contexts'; export type UsePollStateSelectorReturnType = { allowAnswers: boolean | undefined; @@ -63,8 +63,6 @@ const selector = (nextValue: PollState): UsePollStateSelectorReturnType => ({ export const usePollState = (): UsePollStateReturnType => { const { message, poll } = usePollContext(); - const { addNotification } = useNotificationApi(); - const { t } = useTranslationContext(); const { allowAnswers, allowUserSuggestedOptions, @@ -94,28 +92,7 @@ export const usePollState = (): UsePollStateReturnType => { (answerText: string) => poll.addAnswer(answerText, message.id), [message.id, poll], ); - const endVote = useCallback(async () => { - try { - const response = await poll.close(); - addNotification({ - message: t('Poll ended'), - options: { severity: 'success', type: 'api:poll:end:success' }, - origin: { emitter: 'PollActions' }, - }); - return response; - } catch (error) { - addNotification({ - message: t('Failed to end the poll'), - options: { - ...(error instanceof Error ? { originalError: error } : {}), - severity: 'error', - type: 'api:poll:end:failed', - }, - origin: { emitter: 'PollActions' }, - }); - throw error; - } - }, [addNotification, poll, t]); + const endVote = useEndVote(); return { addComment, diff --git a/package/src/components/Poll/hooks/usePollVoteToggle.ts b/package/src/components/Poll/hooks/usePollVoteToggle.ts new file mode 100644 index 0000000000..15104dc38c --- /dev/null +++ b/package/src/components/Poll/hooks/usePollVoteToggle.ts @@ -0,0 +1,34 @@ +import { PollState } from 'stream-chat'; + +import { usePollStateStore } from './usePollStateStore'; + +import { usePollContext } from '../../../contexts'; +import { useStableCallback } from '../../../hooks'; +import { useNotificationApi } from '../../Notifications'; + +const ownVotesSelector = (state: PollState) => ({ + ownVotesByOptionId: state.ownVotesByOptionId, +}); + +/** + * Returns a stable callback that toggles the current user's vote on a poll option + * by id: casts a vote if none exists, removes it if one does. Shared by the + * visible vote button and the rotor accessibility action so both paths use + * identical logic. + */ +export const usePollVoteToggle = () => { + const { message, poll } = usePollContext(); + const { runWithNotificationTarget } = useNotificationApi(); + const { ownVotesByOptionId } = usePollStateStore(ownVotesSelector); + + return useStableCallback(async (optionId: string) => { + await runWithNotificationTarget(async () => { + const existingVoteId = ownVotesByOptionId[optionId]?.id; + if (existingVoteId) { + await poll.removeVote(existingVoteId, message.id); + } else { + await poll.castVote(optionId, message.id); + } + }); + }); +}; diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 337f4d3f2a..6346cc468f 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -7,6 +7,7 @@ import React, { useState, } from 'react'; import { + AccessibilityActionEvent, EventSubscription, Keyboard, KeyboardEvent, @@ -37,11 +38,13 @@ import { getBottomSheetTopSnapIndex, } from './BottomSheetModal.utils'; +import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; import { useResolvedModalAccessibilityProps } from '../../a11y/hooks/useResolvedModalAccessibilityProps'; import { BottomSheetProvider } from '../../contexts/bottomSheetContext/BottomSheetContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../hooks'; import { primitives } from '../../theme'; +import { useAccessibilityAnnouncer } from '../Accessibility/useAccessibilityAnnouncer'; export type BottomSheetModalProps = { /** @@ -495,6 +498,37 @@ const BottomSheetModalInner = (props: PropsWithChildren) const modalA11yProps = useResolvedModalAccessibilityProps(); + const announce = useAccessibilityAnnouncer(); + const openAnnouncement = useA11yLabel( + 'a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.', + ); + const announcedOpenRef = useRef(false); + useEffect(() => { + if (!visible) { + announcedOpenRef.current = false; + return; + } + if (!openAnnouncement || announcedOpenRef.current) { + return; + } + const id = setTimeout(() => { + announce(openAnnouncement, 'polite'); + announcedOpenRef.current = true; + }, 800); + return () => clearTimeout(id); + }, [visible, openAnnouncement, announce]); + + const closeLabel = useA11yLabel('a11y/Close'); + const closeAccessibilityActions = useMemo( + () => (closeLabel ? [{ label: closeLabel, name: 'activate' }] : undefined), + [closeLabel], + ); + const onCloseAccessibilityAction = useStableCallback((event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === 'activate') { + onClose(); + } + }); + const bottomSheetModalContextValue = useMemo( () => ({ close, @@ -515,13 +549,22 @@ const BottomSheetModalInner = (props: PropsWithChildren) - + {renderContent ? ( diff --git a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx index 520a0b55e9..5a95ce3277 100644 --- a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx +++ b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { I18nManager, Platform, @@ -23,6 +23,8 @@ import { PortalHost } from 'react-native-teleport'; import { ClosingPortalHostsLayer } from './ClosingPortalHostsLayer'; +import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; +import { useAccessibilityAnnouncer } from '../../components/Accessibility/useAccessibilityAnnouncer'; import { closeOverlay, finalizeCloseOverlay, @@ -81,6 +83,20 @@ export const MessageOverlayHostLayer = () => { const isActive = !!id; + const announce = useAccessibilityAnnouncer(); + const overlayOpenHint = useA11yLabel('a11y/Swipe right to go through different actions'); + const announcedOpenRef = useRef(false); + useEffect(() => { + if (isActive) { + if (overlayOpenHint && !announcedOpenRef.current) { + announce(overlayOpenHint, 'polite'); + announcedOpenRef.current = true; + } + } else { + announcedOpenRef.current = false; + } + }, [isActive, overlayOpenHint, announce]); + const padding = 8; const minY = topInset + padding; const maxY = screenH - bottomInset - padding; diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index 0acf03fc02..829a76af98 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "تم إلغاء كتم {{ user }}", "size limit": "حد الحجم", "unknown error": "خطأ غير معروف", - "unsupported file type": "نوع ملف غير مدعوم" + "unsupported file type": "نوع ملف غير مدعوم", + "a11y/Activate to view results": "فعّل لعرض النتائج", + "a11y/End vote": "إنهاء التصويت", + "a11y/Show all options": "إظهار جميع الخيارات", + "a11y/Vote on {{option}}": "صوّت على {{option}}", + "a11y/Double tap and hold to activate contextual menu": "انقر نقرًا مزدوجًا مع الاستمرار لتفعيل قائمة السياق", + "a11y/Swipe right to go through different actions": "اسحب لليمين للتنقل بين الإجراءات المختلفة", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 1fa0d7dfcd..d5d3f16471 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -308,6 +308,14 @@ "a11y/Stop voice recording": "Stop voice recording", "a11y/Notifications": "Notifications", "a11y/Dismiss notification": "Dismiss notification", + "a11y/Activate to view results": "Activate to view results", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", + "a11y/Close": "Close", + "a11y/Double tap and hold to activate contextual menu": "Double tap and hold to activate contextual menu", + "a11y/End vote": "End vote", + "a11y/Show all options": "Show all options", + "a11y/Swipe right to go through different actions": "Swipe right to go through different actions", + "a11y/Vote on {{option}}": "Vote on {{option}}", "Attachment upload blocked due to {{reason}}": "Attachment upload blocked due to {{reason}}", "Attachment upload failed due to {{reason}}": "Attachment upload failed due to {{reason}}", "Command not available": "Command not available", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index 7b62c468ce..75fdc566e5 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }} ya no está silenciado", "size limit": "límite de tamaño", "unknown error": "error desconocido", - "unsupported file type": "tipo de archivo no compatible" + "unsupported file type": "tipo de archivo no compatible", + "a11y/Activate to view results": "Activa para ver los resultados", + "a11y/End vote": "Finalizar votación", + "a11y/Show all options": "Mostrar todas las opciones", + "a11y/Vote on {{option}}": "Votar por {{option}}", + "a11y/Double tap and hold to activate contextual menu": "Toca dos veces y mantén pulsado para activar el menú contextual", + "a11y/Swipe right to go through different actions": "Desliza a la derecha para recorrer las diferentes acciones", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index be6cd666db..bc498a9f71 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }} n’est plus en sourdine", "size limit": "limite de taille", "unknown error": "erreur inconnue", - "unsupported file type": "type de fichier non pris en charge" + "unsupported file type": "type de fichier non pris en charge", + "a11y/Activate to view results": "Activer pour voir les résultats", + "a11y/End vote": "Terminer le vote", + "a11y/Show all options": "Afficher toutes les options", + "a11y/Vote on {{option}}": "Voter pour {{option}}", + "a11y/Double tap and hold to activate contextual menu": "Appuyez deux fois et maintenez pour activer le menu contextuel", + "a11y/Swipe right to go through different actions": "Glissez vers la droite pour parcourir les différentes actions", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index cbaca901dd..42c9e51ee0 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }} כבר לא מושתק/ת", "size limit": "מגבלת גודל", "unknown error": "שגיאה לא ידועה", - "unsupported file type": "סוג קובץ לא נתמך" + "unsupported file type": "סוג קובץ לא נתמך", + "a11y/Activate to view results": "הפעל כדי לראות את התוצאות", + "a11y/End vote": "סיים הצבעה", + "a11y/Show all options": "הצג את כל האפשרויות", + "a11y/Vote on {{option}}": "הצבע עבור {{option}}", + "a11y/Double tap and hold to activate contextual menu": "הקש פעמיים והחזק כדי להפעיל את התפריט ההקשרי", + "a11y/Swipe right to go through different actions": "החלק ימינה כדי לעבור בין הפעולות השונות", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index a9a2ac91af..84586cafd8 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }} को अनम्यूट किया गया", "size limit": "आकार सीमा", "unknown error": "अज्ञात त्रुटि", - "unsupported file type": "असमर्थित फ़ाइल प्रकार" + "unsupported file type": "असमर्थित फ़ाइल प्रकार", + "a11y/Activate to view results": "परिणाम देखने के लिए सक्रिय करें", + "a11y/End vote": "मतदान समाप्त करें", + "a11y/Show all options": "सभी विकल्प दिखाएं", + "a11y/Vote on {{option}}": "{{option}} पर वोट करें", + "a11y/Double tap and hold to activate contextual menu": "संदर्भ मेनू सक्रिय करने के लिए दो बार टैप करें और होल्ड करें", + "a11y/Swipe right to go through different actions": "विभिन्न क्रियाओं के बीच जाने के लिए दाएं स्वाइप करें", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 71446281b1..4452fb0009 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }} non è più silenziato", "size limit": "limite di dimensione", "unknown error": "errore sconosciuto", - "unsupported file type": "tipo di file non supportato" + "unsupported file type": "tipo di file non supportato", + "a11y/Activate to view results": "Attiva per vedere i risultati", + "a11y/End vote": "Termina sondaggio", + "a11y/Show all options": "Mostra tutte le opzioni", + "a11y/Vote on {{option}}": "Vota per {{option}}", + "a11y/Double tap and hold to activate contextual menu": "Tocca due volte e tieni premuto per attivare il menu contestuale", + "a11y/Swipe right to go through different actions": "Scorri a destra per passare in rassegna le diverse azioni", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index 57aba2c44b..73f05ecc4a 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }} のミュートを解除しました", "size limit": "サイズ制限", "unknown error": "不明なエラー", - "unsupported file type": "サポートされていないファイル形式" + "unsupported file type": "サポートされていないファイル形式", + "a11y/Activate to view results": "結果を表示するには有効化", + "a11y/End vote": "投票を終了", + "a11y/Show all options": "すべてのオプションを表示", + "a11y/Vote on {{option}}": "{{option}}に投票", + "a11y/Double tap and hold to activate contextual menu": "コンテキストメニューを表示するにはダブルタップして長押し", + "a11y/Swipe right to go through different actions": "右にスワイプして異なるアクションを切り替えます", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index a413f7a831..bec7e20f17 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }}님의 음소거가 해제되었습니다", "size limit": "크기 제한", "unknown error": "알 수 없는 오류", - "unsupported file type": "지원되지 않는 파일 형식" + "unsupported file type": "지원되지 않는 파일 형식", + "a11y/Activate to view results": "결과를 보려면 활성화", + "a11y/End vote": "투표 종료", + "a11y/Show all options": "모든 옵션 표시", + "a11y/Vote on {{option}}": "{{option}}에 투표", + "a11y/Double tap and hold to activate contextual menu": "컨텍스트 메뉴를 활성화하려면 두 번 탭하고 길게 누르세요", + "a11y/Swipe right to go through different actions": "다른 작업을 탐색하려면 오른쪽으로 스와이프하세요", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 07bf5b43d2..1d0a400967 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }} is niet meer gedempt", "size limit": "groottelimiet", "unknown error": "onbekende fout", - "unsupported file type": "niet-ondersteund bestandstype" + "unsupported file type": "niet-ondersteund bestandstype", + "a11y/Activate to view results": "Activeer om resultaten te bekijken", + "a11y/End vote": "Stemming beëindigen", + "a11y/Show all options": "Alle opties weergeven", + "a11y/Vote on {{option}}": "Stem op {{option}}", + "a11y/Double tap and hold to activate contextual menu": "Dubbeltik en houd vast om het contextmenu te openen", + "a11y/Swipe right to go through different actions": "Veeg naar rechts om door verschillende acties te bladeren", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index cd474d7563..54da88ca23 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }} teve o silenciamento removido", "size limit": "limite de tamanho", "unknown error": "erro desconhecido", - "unsupported file type": "tipo de arquivo não compatível" + "unsupported file type": "tipo de arquivo não compatível", + "a11y/Activate to view results": "Ative para ver os resultados", + "a11y/End vote": "Encerrar votação", + "a11y/Show all options": "Mostrar todas as opções", + "a11y/Vote on {{option}}": "Votar em {{option}}", + "a11y/Double tap and hold to activate contextual menu": "Toque duas vezes e segure para ativar o menu contextual", + "a11y/Swipe right to go through different actions": "Deslize para a direita para percorrer as diferentes ações", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 8c84973e3e..32b2f456ed 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }} включен(а)", "size limit": "лимит размера", "unknown error": "неизвестная ошибка", - "unsupported file type": "неподдерживаемый тип файла" + "unsupported file type": "неподдерживаемый тип файла", + "a11y/Activate to view results": "Активируйте, чтобы увидеть результаты", + "a11y/End vote": "Завершить голосование", + "a11y/Show all options": "Показать все варианты", + "a11y/Vote on {{option}}": "Голосовать за {{option}}", + "a11y/Double tap and hold to activate contextual menu": "Дважды коснитесь и удерживайте, чтобы открыть контекстное меню", + "a11y/Swipe right to go through different actions": "Смахните вправо, чтобы переключаться между действиями", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." } diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index fd168ae1af..b3a80c6a3f 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -351,5 +351,13 @@ "{{ user }} has been unmuted": "{{ user }} kullanıcısının sesi açıldı", "size limit": "boyut sınırı", "unknown error": "bilinmeyen hata", - "unsupported file type": "desteklenmeyen dosya türü" + "unsupported file type": "desteklenmeyen dosya türü", + "a11y/Activate to view results": "Sonuçları görmek için etkinleştir", + "a11y/End vote": "Oylamayı sonlandır", + "a11y/Show all options": "Tüm seçenekleri göster", + "a11y/Vote on {{option}}": "{{option}} için oy ver", + "a11y/Double tap and hold to activate contextual menu": "Bağlam menüsünü etkinleştirmek için çift dokunup basılı tut", + "a11y/Swipe right to go through different actions": "Farklı eylemler arasında geçiş yapmak için sağa kaydır", + "a11y/Close": "Close", + "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss." }