diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 9f50ee470f..9f5c174031 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -117,10 +117,9 @@ const ChannelHeader: React.FC = ({ channel }) => { // Either provide channel or channelId. export const ChannelScreen: React.FC = ({ navigation, - route: { - params: { channel: channelFromProp, channelId, messageId }, - }, + route, }) => { + const { channel: channelFromProp, channelId, messageId } = route.params; const { chatClient, messageListImplementation, @@ -180,14 +179,20 @@ export const ChannelScreen: React.FC = ({ if (!thread || !channel) { return; } + + if (messageId) { + navigation.setParams({ messageId: undefined }); + } + setSelectedThread(thread); setThread(thread); navigation.navigate('ThreadScreen', { channel, thread, + targetedMessageId: undefined, }); }, - [channel, navigation, setThread], + [channel, messageId, navigation, setThread], ); const onAlsoSentToChannelHeaderPress = useCallback( @@ -195,6 +200,11 @@ export const ChannelScreen: React.FC = ({ if (!channel || !parentMessage) { return; } + + if (messageId) { + navigation.setParams({ messageId: undefined }); + } + setSelectedThread(parentMessage); setThread(parentMessage); const params: StackNavigatorParamList['ThreadScreen'] = { @@ -202,11 +212,11 @@ export const ChannelScreen: React.FC = ({ targetedMessageId, thread: parentMessage, }; - const hasThreadInStack = navigation.getState().routes.some((route) => { - if (route.name !== 'ThreadScreen') { + const hasThreadInStack = navigation.getState().routes.some((stackRoute) => { + if (stackRoute.name !== 'ThreadScreen') { return false; } - const routeParams = route.params as StackNavigatorParamList['ThreadScreen'] | undefined; + const routeParams = stackRoute.params as StackNavigatorParamList['ThreadScreen'] | undefined; const routeThreadId = (routeParams?.thread as LocalMessage)?.id ?? (routeParams?.thread as ThreadType)?.thread?.id; @@ -221,7 +231,7 @@ export const ChannelScreen: React.FC = ({ navigation.navigate('ThreadScreen', params); }, - [channel, navigation, setThread], + [channel, messageId, navigation, setThread], ); const handleMessageInfo = useCallback((message: LocalMessage) => { diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx index 715ff04515..dc7347ed9f 100644 --- a/examples/SampleApp/src/screens/ThreadScreen.tsx +++ b/examples/SampleApp/src/screens/ThreadScreen.tsx @@ -74,10 +74,9 @@ const ThreadHeader: React.FC = ({ thread }) => { export const ThreadScreen: React.FC = ({ navigation, - route: { - params: { channel, thread, targetedMessageId: targetedMessageIdFromParams }, - }, + route, }) => { + const { channel, thread, targetedMessageId: targetedMessageIdFromParams } = route.params; const { theme: { semantics, @@ -88,6 +87,7 @@ export const ThreadScreen: React.FC = ({ const { t } = useTranslationContext(); const { setThread } = useStreamChatContext(); const { messageInputFloating, messageListImplementation } = useAppContext(); + const onPressMessage: NonNullable['onPressMessage']> = ( payload, ) => { @@ -126,11 +126,11 @@ export const ThreadScreen: React.FC = ({ }; const hasChannelInStack = navigation .getState() - .routes.some((route) => { - if (route.name !== 'ChannelScreen') { + .routes.some((stackRoute) => { + if (stackRoute.name !== 'ChannelScreen') { return false; } - const routeParams = route.params as StackNavigatorParamList['ChannelScreen'] | undefined; + const routeParams = stackRoute.params as StackNavigatorParamList['ChannelScreen'] | undefined; const routeChannelId = routeParams?.channel?.id ?? routeParams?.channelId; return routeChannelId === channel.id; }); diff --git a/package/src/components/Poll/CreatePollContent.tsx b/package/src/components/Poll/CreatePollContent.tsx index 75eb97e370..775cee2d72 100644 --- a/package/src/components/Poll/CreatePollContent.tsx +++ b/package/src/components/Poll/CreatePollContent.tsx @@ -2,7 +2,7 @@ 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 { useSharedValue } from 'react-native-reanimated'; +import Animated, { LinearTransition, useSharedValue } from 'react-native-reanimated'; import { PollComposerState, VotingVisibility } from 'stream-chat'; @@ -153,49 +153,57 @@ export const CreatePollContent = () => { > - + - - - {t('Anonymous voting')} - Hide who voted - + + + + {t('Anonymous voting')} + Hide who voted + - - - - - {t('Suggest an option')} - - Let others add options - + + + + {t('Suggest an option')} + + Let others add options + + - - - - - {t('Add a comment')} - - Add a comment to the poll - + + + + {t('Add a comment')} + + Add a comment to the poll + + - - - + + + + ); diff --git a/package/src/components/Poll/Poll.tsx b/package/src/components/Poll/Poll.tsx index b0e63bbd20..dfaea9d8e7 100644 --- a/package/src/components/Poll/Poll.tsx +++ b/package/src/components/Poll/Poll.tsx @@ -3,7 +3,7 @@ import { StyleSheet, Text, View } from 'react-native'; import { PollOption as PollOptionClass } from 'stream-chat'; -import { PollButtons, PollOption } from './components'; +import { PollButtons, PollOption, ShowAllOptionsButton } from './components'; import { usePollState } from './hooks/usePollState'; @@ -16,6 +16,7 @@ import { } from '../../contexts'; import { primitives } from '../../theme'; +import { defaultPollOptionCount } from '../../utils/constants'; export type PollProps = Pick & Pick; @@ -79,10 +80,11 @@ export const PollContent = ({ {PollHeaderOverride ? : } {options - ?.slice(0, 10) + ?.slice(0, defaultPollOptionCount) ?.map((option: PollOptionClass) => ( ))} + {PollButtonsOverride ? : } diff --git a/package/src/components/Poll/components/CreatePollOptions.tsx b/package/src/components/Poll/components/CreatePollOptions.tsx index 300d970bb5..2bafed4678 100644 --- a/package/src/components/Poll/components/CreatePollOptions.tsx +++ b/package/src/components/Poll/components/CreatePollOptions.tsx @@ -12,7 +12,6 @@ import Animated, { useSharedValue, withDelay, withSpring, - withTiming, } from 'react-native-reanimated'; import { PollComposerOption, PollComposerState } from 'stream-chat'; @@ -580,11 +579,11 @@ export const CreatePollOptions = ({ currentOptionPositions }: CreatePollOptionsP useAnimatedReaction( () => currentOptionPositions.value.totalHeight, (currentValue, previousValue) => { - if (currentValue !== previousValue) { - animatedOptionsContainerHeight.value = withTiming(currentValue, { - duration: 200, - }); + if (currentValue === previousValue) { + return; } + + animatedOptionsContainerHeight.value = currentValue; }, ); @@ -707,7 +706,7 @@ export const CreatePollOptions = ({ currentOptionPositions }: CreatePollOptionsP return ( {t('Options')} - + {options.map((option, index) => ( ({ - error: state.errors.max_votes_allowed, - max_votes_allowed: state.data.max_votes_allowed, -}); export const MultipleAnswersField = () => { const [allowMultipleVotes, setAllowMultipleVotes] = useState(false); const { t } = useTranslationContext(); const messageComposer = useMessageComposer(); const { pollComposer } = messageComposer; - const { handleFieldBlur, updateFields } = pollComposer; - const { error, max_votes_allowed } = useStateStore(pollComposer.state, pollComposerStateSelector); + const { updateFields } = pollComposer; const { theme: { @@ -40,24 +34,16 @@ export const MultipleAnswersField = () => { [updateFields], ); - const onChangeTextHandler = useCallback( - async (newText: string) => { - await updateFields({ max_votes_allowed: newText }); - }, - [updateFields], - ); - - const onBlurHandler = useCallback(async () => { - await handleFieldBlur('max_votes_allowed'); - }, [handleFieldBlur]); - return ( - + - {t('Multiple answers')} + {t('Multiple votes')} - Select more than one option + {t('Select more than one option')} { style={[styles.optionCardSwitch, multipleAnswers.optionCardSwitch]} /> - {allowMultipleVotes ? ( - - ) : null} - + {allowMultipleVotes ? : null} + ); }; @@ -90,14 +63,11 @@ const useStyles = () => { } = useTheme(); return useMemo(() => { return StyleSheet.create({ - maxVotesInput: { - paddingLeft: 0, - }, multipleAnswersWrapper: { backgroundColor: semantics.inputOptionCardBg, padding: primitives.spacingMd, borderRadius: primitives.radiusLg, - gap: primitives.spacingSm, + gap: primitives.spacingMd, }, title: { color: semantics.textPrimary, @@ -119,9 +89,6 @@ const useStyles = () => { justifyContent: 'space-between', flexDirection: 'row', }, - optionCardWrapper: { - gap: primitives.spacingMd, - }, optionCardSwitch: { width: 64 }, }); }, [semantics]); diff --git a/package/src/components/Poll/components/MultipleVotesSettings.tsx b/package/src/components/Poll/components/MultipleVotesSettings.tsx new file mode 100644 index 0000000000..4ad730f768 --- /dev/null +++ b/package/src/components/Poll/components/MultipleVotesSettings.tsx @@ -0,0 +1,229 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; + +import Animated, { LinearTransition, StretchInY, StretchOutY } from 'react-native-reanimated'; + +import { PollComposerState } from 'stream-chat'; + +import { useTheme, useTranslationContext } from '../../../contexts'; +import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; +import { useStableCallback } from '../../../hooks'; +import { useStateStore } from '../../../hooks/useStateStore'; +import { Minus, Plus } from '../../../icons'; +import { primitives } from '../../../theme'; +import { Button } from '../../ui'; + +const pollComposerStateSelector = (state: PollComposerState) => ({ + max_votes_allowed: state.data.max_votes_allowed, +}); + +const MaxVotesTextInput = () => { + const messageComposer = useMessageComposer(); + const { pollComposer } = messageComposer; + const { handleFieldBlur, updateFields } = pollComposer; + const { max_votes_allowed } = useStateStore(pollComposer.state, pollComposerStateSelector); + const { + theme: { + poll: { + createContent: { multipleAnswers }, + }, + }, + } = useTheme(); + const hasSelectedInitialValueRef = useRef(false); + const inputRef = useRef(null); + + const styles = useStyles(); + + const onChangeTextHandler = useCallback( + async (newText: string) => { + await updateFields({ max_votes_allowed: newText }); + }, + [updateFields], + ); + + const onBlurHandler = useCallback(async () => { + await handleFieldBlur('max_votes_allowed'); + }, [handleFieldBlur]); + + useEffect(() => { + if (hasSelectedInitialValueRef.current || max_votes_allowed.length === 0) { + return; + } + + hasSelectedInitialValueRef.current = true; + + const focusFrame = requestAnimationFrame(() => { + inputRef.current?.focus(); + + if (Platform.OS !== 'ios') { + return; + } + + requestAnimationFrame(() => { + inputRef.current?.setNativeProps({ + selection: { + end: max_votes_allowed.length, + start: 0, + }, + }); + }); + }); + + return () => { + cancelAnimationFrame(focusFrame); + }; + }, [max_votes_allowed.length]); + + return ( + + ); +}; + +export const MultipleVotesSettings = () => { + const [allowMaxVotesPerPerson, setAllowMaxVotesPerPerson] = useState(false); + const { t } = useTranslationContext(); + const messageComposer = useMessageComposer(); + const { pollComposer } = messageComposer; + const { updateFields } = pollComposer; + const { max_votes_allowed } = useStateStore(pollComposer.state, pollComposerStateSelector); + const { + theme: { + poll: { + createContent: { multipleAnswers }, + }, + }, + } = useTheme(); + const styles = useStyles(); + + const decrementDisabled = Number(max_votes_allowed) <= 2; + const incrementDisabled = Number(max_votes_allowed) >= 10; + + const decrementMaxVotes = useStableCallback(async () => { + const numericValue = Number(pollComposer.state.getLatestValue().data.max_votes_allowed.trim()); + if (Number.isInteger(numericValue)) { + await updateFields({ max_votes_allowed: String(numericValue - 1) }); + } + }); + + const incrementMaxVotes = useStableCallback(async () => { + const numericValue = Number(pollComposer.state.getLatestValue().data.max_votes_allowed.trim()); + if (Number.isInteger(numericValue)) { + await updateFields({ max_votes_allowed: String(numericValue + 1) }); + } + }); + + const onMaxVotesPerPersonHandler = useStableCallback(async (value: boolean) => { + const currentValue = pollComposer.state.getLatestValue().data.max_votes_allowed; + setAllowMaxVotesPerPerson(value); + await updateFields({ + max_votes_allowed: value ? (currentValue === '' ? '2' : currentValue) : '', + }); + }); + + return ( + + + + {t('Limit votes per person')} + + {t('Choose between 2–10 options')} + + + + + {allowMaxVotesPerPerson ? ( + +