From dd5e3dc3c23aed304798aeab42a7800f5cd04ae5 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 13 Mar 2026 10:53:40 +0100 Subject: [PATCH 01/12] fix: also sent to channel back behaviour --- .../SampleApp/src/screens/ChannelScreen.tsx | 26 +++++++++++++------ .../SampleApp/src/screens/ThreadScreen.tsx | 12 ++++----- 2 files changed, 24 insertions(+), 14 deletions(-) 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; }); From 863e6ba8dd838e285e0f8c1dba173da384d78d26 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 13 Mar 2026 15:34:49 +0100 Subject: [PATCH 02/12] feat: implement new max votes design --- .../src/components/Poll/CreatePollContent.tsx | 77 +++++---- .../Poll/components/CreatePollOptions.tsx | 15 +- .../Poll/components/MultipleAnswersField.tsx | 59 ++----- .../Poll/components/MultipleVotesSettings.tsx | 163 ++++++++++++++++++ .../src/components/Poll/components/index.ts | 1 + package/src/icons/Minus.tsx | 11 ++ package/src/icons/index.ts | 2 + 7 files changed, 242 insertions(+), 86 deletions(-) create mode 100644 package/src/components/Poll/components/MultipleVotesSettings.tsx create mode 100644 package/src/icons/Minus.tsx diff --git a/package/src/components/Poll/CreatePollContent.tsx b/package/src/components/Poll/CreatePollContent.tsx index 75eb97e370..6a19379e81 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'; @@ -155,46 +155,51 @@ 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/components/CreatePollOptions.tsx b/package/src/components/Poll/components/CreatePollOptions.tsx index 300d970bb5..86ef397303 100644 --- a/package/src/components/Poll/components/CreatePollOptions.tsx +++ b/package/src/components/Poll/components/CreatePollOptions.tsx @@ -580,11 +580,18 @@ export const CreatePollOptions = ({ currentOptionPositions }: CreatePollOptionsP useAnimatedReaction( () => currentOptionPositions.value.totalHeight, (currentValue, previousValue) => { - if (currentValue !== previousValue) { - animatedOptionsContainerHeight.value = withTiming(currentValue, { - duration: 200, - }); + if (currentValue === previousValue) { + return; + } + + if (animatedOptionsContainerHeight.value === 0 && currentValue > 0) { + animatedOptionsContainerHeight.value = currentValue; + return; } + + animatedOptionsContainerHeight.value = withTiming(currentValue, { + duration: 200, + }); }, ); diff --git a/package/src/components/Poll/components/MultipleAnswersField.tsx b/package/src/components/Poll/components/MultipleAnswersField.tsx index 2d28921637..f7cd0805ef 100644 --- a/package/src/components/Poll/components/MultipleAnswersField.tsx +++ b/package/src/components/Poll/components/MultipleAnswersField.tsx @@ -1,26 +1,20 @@ import React, { useCallback, useMemo, useState } from 'react'; import { StyleSheet, Switch, Text, View } from 'react-native'; -import { PollComposerState } from 'stream-chat'; +import Animated, { LinearTransition } from 'react-native-reanimated'; + +import { MultipleVotesSettings } from './MultipleVotesSettings'; import { useTheme, useTranslationContext } from '../../../contexts'; import { useMessageComposer } from '../../../contexts/messageInputContext/hooks/useMessageComposer'; -import { useStateStore } from '../../../hooks/useStateStore'; import { primitives } from '../../../theme'; -import { Input } from '../../ui/Input/Input'; - -const pollComposerStateSelector = (state: PollComposerState) => ({ - 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..0f27f3676f --- /dev/null +++ b/package/src/components/Poll/components/MultipleVotesSettings.tsx @@ -0,0 +1,163 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { 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) => ({ + error: state.errors.max_votes_allowed, + max_votes_allowed: state.data.max_votes_allowed, +}); + +export const MultipleVotesSettings = () => { + const [allowMaxVotesPerPerson, setAllowMaxVotesPerPerson] = useState(false); + const { t } = useTranslationContext(); + 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 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 onChangeTextHandler = useCallback( + async (newText: string) => { + await updateFields({ max_votes_allowed: newText }); + }, + [updateFields], + ); + + const onBlurHandler = useCallback(async () => { + await handleFieldBlur('max_votes_allowed'); + }, [handleFieldBlur]); + + 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 ? ( + +