import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { StyleSheet, Switch, Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import Animated, { LinearTransition, useSharedValue } from 'react-native-reanimated'; import { PollComposerState, StateStore, VotingVisibility } from 'stream-chat'; import { CreatePollOptions, CurrentOptionPositionsCache } from './components'; import { CreatePollHeader } from './components/CreatePollHeader'; import { MultipleAnswersField } from './components/MultipleAnswersField'; import { NameField } from './components/NameField'; import { CreatePollModalState, CreatePollContentContextValue, CreatePollContentProvider, useCreatePollContentContext, useTheme, useTranslationContext, } from '../../contexts'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; import { useRtlMirrorSwitchStyle } from '../../utils/rtlMirrorSwitchStyle'; const pollComposerStateSelector = (state: PollComposerState) => ({ options: state.data.options, }); export const CreatePollContent = () => { const [isAnonymousPoll, setIsAnonymousPoll] = useState(false); const [allowUserSuggestedOptions, setAllowUserSuggestedOptions] = useState(false); const [allowAnswers, setAllowAnswers] = useState(false); const { t } = useTranslationContext(); const messageComposer = useMessageComposer(); const { pollComposer } = messageComposer; const { options } = useStateStore(pollComposer.state, pollComposerStateSelector); const { createPollOptionGap = 8, closePollCreationDialog, createAndSendPoll, } = useCreatePollContentContext(); const normalizedCreatePollOptionGap = Number.isFinite(createPollOptionGap) && createPollOptionGap > 0 ? createPollOptionGap : 0; const optionIdsKey = useMemo(() => options.map((option) => option.id).join('|'), [options]); const optionsRef = useRef(options); optionsRef.current = options; // positions and index lookup map // TODO: Please rethink the structure of this, bidirectional data flow is not great const currentOptionPositions = useSharedValue({ inverseIndexCache: {}, positionCache: {}, totalHeight: 0, }); const { theme: { poll: { createContent: { addComment, anonymousPoll, optionCardWrapper, scrollView, suggestOption }, }, }, } = useTheme(); const styles = useStyles(); useEffect(() => { const latestOptions = optionsRef.current; const currentPositions = currentOptionPositions.value; const isCacheAlignedWithOptions = latestOptions.length === Object.keys(currentPositions.inverseIndexCache).length && latestOptions.every( (option, index) => currentPositions.inverseIndexCache[index] === option.id && currentPositions.positionCache[option.id] !== undefined, ); // Avoid overwriting freshly measured heights/tops from CreatePollOptions onLayout. // We only need this effect when options ids/order introduced missing cache entries. if (isCacheAlignedWithOptions) { return; } const previousPositionCache = currentOptionPositions.value.positionCache; const newCurrentOptionPositions: CurrentOptionPositionsCache = { inverseIndexCache: {}, positionCache: {}, totalHeight: 0, }; let runningTop = 0; latestOptions.forEach((option, index) => { const preservedHeight = previousPositionCache[option.id]?.updatedHeight ?? 0; newCurrentOptionPositions.inverseIndexCache[index] = option.id; newCurrentOptionPositions.positionCache[option.id] = { updatedHeight: preservedHeight, updatedIndex: index, updatedTop: runningTop, }; const gap = index === latestOptions.length - 1 ? 0 : normalizedCreatePollOptionGap; runningTop += preservedHeight + gap; newCurrentOptionPositions.totalHeight = runningTop; }); currentOptionPositions.value = newCurrentOptionPositions; }, [currentOptionPositions, normalizedCreatePollOptionGap, optionIdsKey]); const onBackPressHandler = useCallback(() => { closePollCreationDialog?.(); }, [closePollCreationDialog]); const onCreatePollPressHandler = useCallback(async () => { await createAndSendPoll(); }, [createAndSendPoll]); const onAnonymousPollChangeHandler = useCallback( async (value: boolean) => { setIsAnonymousPoll(value); await pollComposer.updateFields({ voting_visibility: value ? VotingVisibility.anonymous : VotingVisibility.public, }); }, [pollComposer], ); const onAllowUserSuggestedOptionsChangeHandler = useCallback( async (value: boolean) => { setAllowUserSuggestedOptions(value); await pollComposer.updateFields({ allow_user_suggested_options: value }); }, [pollComposer], ); const onAllowAnswersChangeHandler = useCallback( async (value: boolean) => { setAllowAnswers(value); await pollComposer.updateFields({ allow_answers: value }); }, [pollComposer], ); return ( <> {t('Anonymous voting')} Hide who voted {t('Suggest an option')} Let others add options {t('Add a comment')} Add a comment to the poll ); }; export const CreatePoll = ({ closePollCreationDialog, createPollOptionGap = 8, sendMessage, }: Pick< CreatePollContentContextValue, 'createPollOptionGap' | 'closePollCreationDialog' | 'sendMessage' >) => { const { CreatePollContent: CreatePollContentOverride } = useComponentsContext(); const messageComposer = useMessageComposer(); const [modalStateStore] = useState( () => new StateStore({ isClosing: false }), ); const closeFrameRef = useRef(null); const closeCreatePollDialog = useCallback(() => { if (closeFrameRef.current !== null || modalStateStore.getLatestValue().isClosing) { return; } // Let the modal render once with exit animations disabled before we dismiss it. modalStateStore.partialNext({ isClosing: true }); closeFrameRef.current = requestAnimationFrame(() => { closeFrameRef.current = null; closePollCreationDialog?.(); }); }, [closePollCreationDialog, modalStateStore]); useEffect(() => { return () => { if (closeFrameRef.current !== null) { cancelAnimationFrame(closeFrameRef.current); } // Reset after teardown so poll field exit animations do not delay modal dismissal. messageComposer.pollComposer.initState(); }; }, [messageComposer]); const createAndSendPoll = useCallback(async () => { try { await messageComposer.createPoll(); await sendMessage(); closeCreatePollDialog(); } catch (error) { console.log('Error creating a poll and sending a message:', error); } }, [closeCreatePollDialog, messageComposer, sendMessage]); return ( {CreatePollContentOverride ? : } ); }; const useStyles = () => { const { theme: { semantics }, } = useTheme(); const rtlMirrorSwitchStyle = useRtlMirrorSwitchStyle(); return useMemo(() => { return StyleSheet.create({ scrollView: { flex: 1, backgroundColor: semantics.backgroundCoreElevation1, }, contentContainerStyle: { alignItems: 'stretch', padding: primitives.spacingMd, paddingBottom: 70, width: '100%', }, title: { color: semantics.textPrimary, fontSize: primitives.typographyFontSizeMd, fontWeight: primitives.typographyFontWeightSemiBold, lineHeight: primitives.typographyLineHeightNormal, textAlign: 'left', }, description: { color: semantics.textTertiary, fontSize: primitives.typographyFontSizeSm, fontWeight: primitives.typographyFontWeightRegular, lineHeight: primitives.typographyLineHeightNormal, textAlign: 'left', }, optionCardContent: { gap: primitives.spacingXxs, flex: 1, alignItems: 'flex-start', }, optionCard: { flex: 1, alignItems: 'center', justifyContent: 'space-between', flexDirection: 'row', backgroundColor: semantics.backgroundCoreSurfaceCard, padding: primitives.spacingMd, borderRadius: primitives.radiusLg, }, optionCardWrapper: { gap: primitives.spacingMd, }, optionCardSwitch: { width: 64, ...rtlMirrorSwitchStyle }, }); }, [rtlMirrorSwitchStyle, semantics]); };