diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 36493165b3..c547cbda03 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -578,6 +578,7 @@ const ChannelWithContext = (props: PropsWithChildren) = compressImageQuality, CooldownTimer = CooldownTimerDefault, CreatePollContent, + createPollOptionGap, customMessageSwipeAction, DateHeader = DateHeaderDefault, deletedMessagesVisibilityType = 'always', @@ -1828,6 +1829,7 @@ const ChannelWithContext = (props: PropsWithChildren) = compressImageQuality, CooldownTimer, CreatePollContent, + createPollOptionGap, doFileUploadRequest, editMessage, FileAttachmentUploadPreview, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 46a7001697..3046035e6b 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -28,6 +28,7 @@ export const useCreateInputMessageInputContext = ({ compressImageQuality, CooldownTimer, CreatePollContent, + createPollOptionGap, doFileUploadRequest, editMessage, FileAttachmentUploadPreview, @@ -89,6 +90,7 @@ export const useCreateInputMessageInputContext = ({ compressImageQuality, CooldownTimer, CreatePollContent, + createPollOptionGap, doFileUploadRequest, editMessage, FileAttachmentUploadPreview, diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 976f83da18..bb56b3ad83 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -172,6 +172,7 @@ type MessageInputPropsWithContext = Pick & | 'showPollCreationDialog' | 'sendMessage' | 'CreatePollContent' + | 'createPollOptionGap' | 'StopMessageStreamingButton' > & Pick & @@ -206,6 +207,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { closeAttachmentPicker, closePollCreationDialog, CreatePollContent, + createPollOptionGap, editing, messageInputFloating, messageInputHeightStore, @@ -463,6 +465,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { @@ -486,6 +489,7 @@ const areEqual = ( audioRecordingEnabled: prevAsyncMessagesEnabled, channel: prevChannel, closePollCreationDialog: prevClosePollCreationDialog, + createPollOptionGap: prevCreatePollOptionGap, editing: prevEditing, isKeyboardVisible: prevIsKeyboardVisible, isOnline: prevIsOnline, @@ -505,6 +509,7 @@ const areEqual = ( audioRecordingEnabled: nextAsyncMessagesEnabled, channel: nextChannel, closePollCreationDialog: nextClosePollCreationDialog, + createPollOptionGap: nextCreatePollOptionGap, editing: nextEditing, isKeyboardVisible: nextIsKeyboardVisible, isOnline: nextIsOnline, @@ -525,6 +530,7 @@ const areEqual = ( const pollCreationInputPropsEqual = prevOpenPollCreationDialog === nextOpenPollCreationDialog && prevClosePollCreationDialog === nextClosePollCreationDialog && + prevCreatePollOptionGap === nextCreatePollOptionGap && prevShowPollCreationDialog === nextShowPollCreationDialog; if (!pollCreationInputPropsEqual) { return false; diff --git a/package/src/components/Poll/CreatePollContent.tsx b/package/src/components/Poll/CreatePollContent.tsx index 87d713625b..a7323fe974 100644 --- a/package/src/components/Poll/CreatePollContent.tsx +++ b/package/src/components/Poll/CreatePollContent.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { StyleSheet, Switch, Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; @@ -23,7 +23,6 @@ import { import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; -import { POLL_OPTION_HEIGHT } from '../../utils/constants'; const pollComposerStateSelector = (state: PollComposerState) => ({ options: state.data.options, @@ -40,14 +39,23 @@ export const CreatePollContent = () => { const { pollComposer } = messageComposer; const { options } = useStateStore(pollComposer.state, pollComposerStateSelector); - const { createPollOptionHeight, closePollCreationDialog, createAndSendPoll } = - useCreatePollContentContext(); + 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 { @@ -61,20 +69,43 @@ export const CreatePollContent = () => { const styles = useStyles(); useEffect(() => { - if (!createPollOptionHeight) return; + 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, }; - options.forEach((option, index) => { + 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: index * createPollOptionHeight, + updatedTop: runningTop, }; + const gap = index === latestOptions.length - 1 ? 0 : normalizedCreatePollOptionGap; + runningTop += preservedHeight + gap; + newCurrentOptionPositions.totalHeight = runningTop; }); currentOptionPositions.value = newCurrentOptionPositions; - }, [createPollOptionHeight, currentOptionPositions, options]); + }, [currentOptionPositions, normalizedCreatePollOptionGap, optionIdsKey]); const onBackPressHandler = useCallback(() => { pollComposer.initState(); @@ -174,11 +205,11 @@ export const CreatePollContent = () => { export const CreatePoll = ({ closePollCreationDialog, CreatePollContent: CreatePollContentOverride, - createPollOptionHeight = POLL_OPTION_HEIGHT, + createPollOptionGap = 8, sendMessage, }: Pick< CreatePollContentContextValue, - 'createPollOptionHeight' | 'closePollCreationDialog' | 'sendMessage' + 'createPollOptionGap' | 'closePollCreationDialog' | 'sendMessage' > & Pick) => { const messageComposer = useMessageComposer(); @@ -199,7 +230,12 @@ export const CreatePoll = ({ return ( {CreatePollContentOverride ? : } @@ -241,9 +277,7 @@ const useStyles = () => { optionCardWrapper: { gap: primitives.spacingMd, }, - optionCardSwitch: { - marginRight: primitives.spacingMd, - }, + optionCardSwitch: {}, }); }, [semantics]); }; diff --git a/package/src/components/Poll/components/CreatePollOptions.tsx b/package/src/components/Poll/components/CreatePollOptions.tsx index 9a6872c421..300d970bb5 100644 --- a/package/src/components/Poll/components/CreatePollOptions.tsx +++ b/package/src/components/Poll/components/CreatePollOptions.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { LayoutChangeEvent, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { interpolate, + LinearTransition, runOnJS, SharedValue, useAnimatedReaction, @@ -23,7 +24,6 @@ import { CircleMinus } from '../../../icons/CircleMinus'; import { DotGrid } from '../../../icons/DotGrid'; import { InfoTooltip } from '../../../icons/InfoTooltip'; import { primitives } from '../../../theme'; -import { POLL_OPTION_HEIGHT } from '../../../utils/constants'; export type CurrentOptionPositionsCache = { inverseIndexCache: { @@ -31,14 +31,16 @@ export type CurrentOptionPositionsCache = { }; positionCache: { [key: string]: { + updatedHeight: number; updatedIndex: number; updatedTop: number; }; }; + totalHeight: number; }; export type CreatePollOptionType = { - boundaries: { maxBound: number; minBound: number }; + optionsCount: number; currentOptionPositions: SharedValue; draggedItemId: SharedValue; error?: string; @@ -46,18 +48,223 @@ export type CreatePollOptionType = { handleBlur: () => void; index: number; isDragging: SharedValue<1 | 0>; - option: PollComposerOption; + optionId: string; + onOptionLayout: (optionId: string, height: number) => void; /** * * @param newOrder The inverse index object of the new options position after re-ordering. * @returns */ onNewOrder: (newOrder: CurrentOptionPositionsCache['inverseIndexCache']) => void; - onRemoveOption: (index: number) => void; + onRemoveOption: (optionId: string) => void; +}; + +// Run after two frames so nested layout (including error rows) has settled. +const runAfterNextPaint = (cb: () => void) => { + requestAnimationFrame(() => { + requestAnimationFrame(cb); + }); +}; + +const recalculatePositionCache = ( + inverseIndexCache: CurrentOptionPositionsCache['inverseIndexCache'], + positionCache: CurrentOptionPositionsCache['positionCache'], + gap: number, +) => { + 'worklet'; + + const updatedPositionCache = { ...positionCache }; + const indices = Object.keys(inverseIndexCache) + .map((key) => Number(key)) + .sort((a, b) => a - b); + + let runningTop = 0; + + for (let i = 0; i < indices.length; i++) { + const index = indices[i]; + const optionId = inverseIndexCache[index]; + const currentPosition = optionId ? updatedPositionCache[optionId] : undefined; + + if (!optionId) { + continue; + } + + const updatedHeight = + currentPosition && + Number.isFinite(currentPosition.updatedHeight) && + currentPosition.updatedHeight > 0 + ? currentPosition.updatedHeight + : 0; + updatedPositionCache[optionId] = { + ...(currentPosition ?? {}), + updatedHeight, + updatedIndex: index, + updatedTop: runningTop, + }; + runningTop += updatedHeight + (i === indices.length - 1 ? 0 : gap); + } + + return { + positionCache: updatedPositionCache, + totalHeight: runningTop, + }; +}; + +const findTargetIndex = ( + inverseIndexCache: CurrentOptionPositionsCache['inverseIndexCache'], + positionCache: CurrentOptionPositionsCache['positionCache'], + currentIndex: number, + draggedTop: number, + draggedBottom: number, + translationY: number, +) => { + 'worklet'; + + const indices = Object.keys(inverseIndexCache) + .map((key) => Number(key)) + .sort((a, b) => a - b); + + if (!indices.length) { + return 0; + } + + let targetIndex = currentIndex; + + if (translationY > 0) { + // Moving down: cross next sibling center with dragged bottom edge. + while (targetIndex < indices.length - 1) { + const nextIndex = targetIndex + 1; + const nextOptionId = inverseIndexCache[nextIndex]; + if (!nextOptionId) { + break; + } + const nextPosition = positionCache[nextOptionId]; + if (!nextPosition) { + break; + } + const nextCenter = nextPosition.updatedTop + nextPosition.updatedHeight / 2; + if (draggedBottom > nextCenter) { + targetIndex = nextIndex; + continue; + } + break; + } + } else if (translationY < 0) { + // Moving up: cross previous sibling center with dragged top edge. + while (targetIndex > 0) { + const previousIndex = targetIndex - 1; + const previousOptionId = inverseIndexCache[previousIndex]; + if (!previousOptionId) { + break; + } + const previousPosition = positionCache[previousOptionId]; + if (!previousPosition) { + break; + } + const previousCenter = previousPosition.updatedTop + previousPosition.updatedHeight / 2; + if (draggedTop < previousCenter) { + targetIndex = previousIndex; + continue; + } + break; + } + } + + return targetIndex; +}; + +const getSortedIndices = (inverseIndexCache: CurrentOptionPositionsCache['inverseIndexCache']) => + Object.keys(inverseIndexCache) + .map((key) => Number(key)) + .sort((a, b) => a - b); + +const reconcileInverseIndexCacheWithOptionIds = ( + inverseIndexCache: CurrentOptionPositionsCache['inverseIndexCache'], + latestOptionIds: string[], +) => { + const existingOrder: string[] = []; + const latestIdSet = new Set(latestOptionIds); + const sortedIndices = getSortedIndices(inverseIndexCache); + + for (let i = 0; i < sortedIndices.length; i++) { + const optionId = inverseIndexCache[sortedIndices[i]]; + if (!optionId || !latestIdSet.has(optionId)) { + continue; + } + existingOrder.push(optionId); + } + + const existingIdSet = new Set(existingOrder); + const missingOptionIds = latestOptionIds.filter((id) => !existingIdSet.has(id)); + const nextOrder = [...existingOrder, ...missingOptionIds]; + + const nextInverse: CurrentOptionPositionsCache['inverseIndexCache'] = {}; + for (let i = 0; i < nextOrder.length; i++) { + nextInverse[i] = nextOrder[i]; + } + + return nextInverse; +}; + +const reorderInverseIndexCache = ( + inverseIndexCache: CurrentOptionPositionsCache['inverseIndexCache'], + draggedItemId: string, + targetIndex: number, +) => { + 'worklet'; + + const indices = Object.keys(inverseIndexCache) + .map((key) => Number(key)) + .sort((a, b) => a - b); + const orderedIds: string[] = []; + + for (let i = 0; i < indices.length; i++) { + const optionId = inverseIndexCache[indices[i]]; + if (!optionId || optionId === draggedItemId) { + continue; + } + orderedIds.push(optionId); + } + + const boundedIndex = Math.min(Math.max(targetIndex, 0), orderedIds.length); + orderedIds.splice(boundedIndex, 0, draggedItemId); + + const nextInverse: CurrentOptionPositionsCache['inverseIndexCache'] = {}; + for (let i = 0; i < orderedIds.length; i++) { + nextInverse[i] = orderedIds[i]; + } + + return nextInverse; +}; + +const getFallbackTopForIndex = ( + index: number, + positionCache: CurrentOptionPositionsCache['positionCache'], + gap: number, +) => { + const knownPositions = Object.values(positionCache) + .filter( + (position) => + Number.isFinite(position.updatedIndex) && + position.updatedIndex >= 0 && + Number.isFinite(position.updatedHeight) && + position.updatedHeight >= 0, + ) + .sort((a, b) => a.updatedIndex - b.updatedIndex); + + let runningTop = 0; + for (let i = 0; i < knownPositions.length; i++) { + if (knownPositions[i].updatedIndex >= index) { + break; + } + runningTop += knownPositions[i].updatedHeight + gap; + } + + return runningTop; }; export const CreatePollOption = ({ - boundaries, + optionsCount, currentOptionPositions, draggedItemId, error, @@ -65,19 +272,31 @@ export const CreatePollOption = ({ handleChangeText, index, isDragging, - option, + optionId, + onOptionLayout, onNewOrder, onRemoveOption, }: CreatePollOptionType) => { const { t } = useTranslationContext(); - const { createPollOptionHeight = POLL_OPTION_HEIGHT } = useCreatePollContentContext(); - const top = useSharedValue(index * createPollOptionHeight); + const { createPollOptionGap = 8 } = useCreatePollContentContext(); + const normalizedCreatePollOptionGap = + Number.isFinite(createPollOptionGap) && createPollOptionGap > 0 ? createPollOptionGap : 0; + const initialTop = + currentOptionPositions.value.positionCache[optionId]?.updatedTop ?? + getFallbackTopForIndex( + index, + currentOptionPositions.value.positionCache, + normalizedCreatePollOptionGap, + ); + const top = useSharedValue(initialTop); + const optionContainerRef = useRef(null); + const isMeasurementScheduledRef = useRef(false); const isDraggingDerived = useDerivedValue(() => isDragging.value); const draggedItemIdDerived = useDerivedValue(() => draggedItemId.value); const isCurrentDraggingItem = useDerivedValue( - () => isDraggingDerived.value && draggedItemIdDerived.value === option.id, + () => isDraggingDerived.value && draggedItemIdDerived.value === optionId, ); const animatedStyles = useAnimatedStyle(() => ({ @@ -94,115 +313,120 @@ export const CreatePollOption = ({ () => currentOptionPositions.value, ); - // used for swapping with currentIndex + // Target index computed during drag. const newIndex = useSharedValue(null); - // used for swapping with newIndex + // Current dragged item's index in the live reorder cache. const currentIndex = useSharedValue(null); - // The sanity check for position cache updated index, is added because after a poll is sent its been reset - // by the composer and it throws an undefined error. This can be removed in future. + // Keep drag translation anchored to drag-start top. + const dragStartTop = useSharedValue(0); + const previousDragTop = useSharedValue(0); + useAnimatedReaction( - () => currentOptionPositionsDerived.value.positionCache[option.id]?.updatedIndex ?? 0, + () => currentOptionPositionsDerived.value.positionCache[optionId]?.updatedTop ?? 0, (currentValue, previousValue) => { - if (currentValue !== previousValue) { - const updatedIndex = - currentOptionPositionsDerived.value.positionCache[option.id]?.updatedIndex ?? 0; - top.value = withSpring(updatedIndex * createPollOptionHeight); + if (currentValue !== previousValue && !isCurrentDraggingItem.value) { + top.value = withSpring(currentValue); } }, ); const gesture = Gesture.Pan() .onStart(() => { + const currentPosition = currentOptionPositionsDerived.value.positionCache[optionId]; + if (!currentPosition) { + return; + } + // start dragging isDragging.value = withSpring(1); // keep track of dragged item - draggedItemId.value = option.id; + draggedItemId.value = optionId; - // store dragged item id for future swap - currentIndex.value = - currentOptionPositionsDerived.value.positionCache[option.id].updatedIndex; + // capture drag start position/index + currentIndex.value = currentPosition.updatedIndex; + dragStartTop.value = currentPosition.updatedTop; + previousDragTop.value = currentPosition.updatedTop; }) .onUpdate((e) => { - const { inverseIndexCache, positionCache } = currentOptionPositionsDerived.value; + const { inverseIndexCache, positionCache, totalHeight } = currentOptionPositionsDerived.value; if (draggedItemIdDerived.value === null || currentIndex.value === null) { return; } - const newTop = positionCache[draggedItemIdDerived.value].updatedTop + e.translationY; + + const draggedItemPosition = positionCache[draggedItemIdDerived.value]; + if (!draggedItemPosition) { + return; + } + + const maxBound = Math.max(totalHeight - draggedItemPosition.updatedHeight, 0); + const newTop = dragStartTop.value + e.translationY; + const frameDeltaY = newTop - previousDragTop.value; + previousDragTop.value = newTop; + // we add a small leeway to account for sharp animations which tend to bug out otherwise - if (newTop < boundaries.minBound - 10 || newTop > boundaries.maxBound + 10) { + if (newTop < -10 || newTop > maxBound + 10) { // out of bounds, exit out of the animation early return; } top.value = newTop; // calculate the new index where drag is headed to - newIndex.value = Math.floor((newTop + createPollOptionHeight / 2) / createPollOptionHeight); - - // swap the items present at newIndex and currentIndex + const draggedTop = newTop; + const draggedBottom = newTop + draggedItemPosition.updatedHeight; + newIndex.value = findTargetIndex( + inverseIndexCache, + positionCache, + currentIndex.value, + draggedTop, + draggedBottom, + frameDeltaY, + ); + + // Reorder by inserting dragged item into target slot. if (newIndex.value !== currentIndex.value) { - // find id of the item that currently resides at newIndex - const newIndexItemKey = inverseIndexCache[newIndex.value]; - - // find id of the item that currently resides at currentIndex - const currentDragIndexItemKey = inverseIndexCache[currentIndex.value]; - - if (newIndexItemKey !== undefined && currentDragIndexItemKey !== undefined) { - // if we indeed have a candidate for a new index, we update our cache so that - // it can be reflected through animations - currentOptionPositions.value = { - inverseIndexCache: { - ...inverseIndexCache, - [newIndex.value]: currentDragIndexItemKey, - [currentIndex.value]: newIndexItemKey, - }, - positionCache: { - ...positionCache, - [currentDragIndexItemKey]: { - ...positionCache[currentDragIndexItemKey], - updatedIndex: newIndex.value, - }, - [newIndexItemKey]: { - ...positionCache[newIndexItemKey], - updatedIndex: currentIndex.value, - updatedTop: currentIndex.value * createPollOptionHeight, - }, - }, - }; - - // update new index as current index - currentIndex.value = newIndex.value; - } + const nextInverseIndexCache = reorderInverseIndexCache( + inverseIndexCache, + draggedItemIdDerived.value, + newIndex.value, + ); + const recalculated = recalculatePositionCache( + nextInverseIndexCache, + positionCache, + normalizedCreatePollOptionGap, + ); + + currentOptionPositions.value = { + inverseIndexCache: nextInverseIndexCache, + positionCache: recalculated.positionCache, + totalHeight: recalculated.totalHeight, + }; + + currentIndex.value = newIndex.value; } }) .onEnd(() => { const { inverseIndexCache, positionCache } = currentOptionPositionsDerived.value; - if (currentIndex.value === null || newIndex.value === null) { + if (currentIndex.value === null) { + isDragging.value = withDelay(200, withSpring(0)); + draggedItemId.value = null; return; } - top.value = withSpring(newIndex.value * createPollOptionHeight); - - // find original id of the item that currently resides at currentIndex const currentDragIndexItemKey = inverseIndexCache[currentIndex.value]; + const currentDragTop = + currentDragIndexItemKey !== undefined + ? positionCache[currentDragIndexItemKey]?.updatedTop + : undefined; + top.value = withSpring(currentDragTop ?? top.value); - if (currentDragIndexItemKey !== undefined) { - // update the values for item whose drag we just stopped - currentOptionPositions.value = { - ...currentOptionPositionsDerived.value, - positionCache: { - ...positionCache, - [currentDragIndexItemKey]: { - ...positionCache[currentDragIndexItemKey], - updatedTop: newIndex.value * createPollOptionHeight, - }, - }, - }; - } // stop dragging isDragging.value = withDelay(200, withSpring(0)); + draggedItemId.value = null; + currentIndex.value = null; + newIndex.value = null; runOnJS(onNewOrder)(currentOptionPositionsDerived.value.inverseIndexCache); }); @@ -226,11 +450,47 @@ export const CreatePollOption = ({ ); const onRemoveOptionHandler = useCallback(() => { - onRemoveOption(index); - }, [onRemoveOption, index]); + onRemoveOption(optionId); + }, [onRemoveOption, optionId]); + + const onLayoutHandler = useCallback( + (event: LayoutChangeEvent) => { + onOptionLayout(optionId, event.nativeEvent.layout.height); + }, + [onOptionLayout, optionId], + ); + + const measureOptionHeight = useCallback(() => { + if (isMeasurementScheduledRef.current) { + return; + } + isMeasurementScheduledRef.current = true; + runAfterNextPaint(() => { + const currentOptionContainer = optionContainerRef.current; + if (!currentOptionContainer) { + isMeasurementScheduledRef.current = false; + return; + } + currentOptionContainer.measure((_x, _y, _width, height) => { + isMeasurementScheduledRef.current = false; + if (Number.isFinite(height) && height > 0) { + onOptionLayout(optionId, height); + } + }); + }); + }, [onOptionLayout, optionId]); + const onErrorLayoutHandler = useCallback(() => { + measureOptionHeight(); + }, [measureOptionHeight]); + + useEffect(() => { + measureOptionHeight(); + }, [error, measureOptionHeight, optionsCount]); return ( - + @@ -256,10 +516,13 @@ export const CreatePollOption = ({ - + {error ? ( - + {t(error)} @@ -286,7 +549,11 @@ export const CreatePollOptions = ({ currentOptionPositions }: CreatePollOptionsP const messageComposer = useMessageComposer(); const { pollComposer } = messageComposer; const { errors, options } = useStateStore(pollComposer.state, pollComposerStateSelector); - const { createPollOptionHeight = POLL_OPTION_HEIGHT } = useCreatePollContentContext(); + const { createPollOptionGap = 8 } = useCreatePollContentContext(); + const normalizedCreatePollOptionGap = + Number.isFinite(createPollOptionGap) && createPollOptionGap > 0 ? createPollOptionGap : 0; + const optionsRef = useRef(options); + optionsRef.current = options; const updateOption = useCallback( (newText: string, index: number) => { @@ -308,23 +575,23 @@ export const CreatePollOptions = ({ currentOptionPositions }: CreatePollOptionsP const draggedItemId = useSharedValue(null); // holds the animated height of the option container - const animatedOptionsContainerHeight = useSharedValue(createPollOptionHeight * options.length); + const animatedOptionsContainerHeight = useSharedValue(currentOptionPositions.value.totalHeight); - useEffect(() => { - animatedOptionsContainerHeight.value = withTiming(createPollOptionHeight * options.length, { - duration: 200, - }); - }, [animatedOptionsContainerHeight, createPollOptionHeight, options.length]); + useAnimatedReaction( + () => currentOptionPositions.value.totalHeight, + (currentValue, previousValue) => { + if (currentValue !== previousValue) { + animatedOptionsContainerHeight.value = withTiming(currentValue, { + duration: 200, + }); + } + }, + ); const animatedOptionsContainerStyle = useAnimatedStyle(() => ({ height: animatedOptionsContainerHeight.value, })); - const boundaries = useMemo( - () => ({ maxBound: (options.length - 1) * createPollOptionHeight, minBound: 0 }), - [createPollOptionHeight, options.length], - ); - const { theme: { poll: { @@ -338,10 +605,19 @@ export const CreatePollOptions = ({ currentOptionPositions }: CreatePollOptionsP const onNewOrderChange = useCallback( async (newOrder: CurrentOptionPositionsCache['inverseIndexCache']) => { - const reorderedPollOptions = []; + const latestOptions = optionsRef.current; + const optionById: Record = {}; + for (let i = 0; i < latestOptions.length; i++) { + optionById[latestOptions[i].id] = latestOptions[i]; + } - for (let i = 0; i < options.length; i++) { - const currentOption = options.find((option) => option.id === newOrder[i]); + const reorderedPollOptions: PollComposerOption[] = []; + for (let i = 0; i < latestOptions.length; i++) { + const optionId = newOrder[i]; + if (!optionId) { + continue; + } + const currentOption = optionById[optionId]; if (currentOption) { reorderedPollOptions.push(currentOption); } @@ -351,19 +627,81 @@ export const CreatePollOptions = ({ currentOptionPositions }: CreatePollOptionsP options: reorderedPollOptions, }); }, - [options, pollComposer], + [pollComposer], ); const onRemoveOptionHandler = useCallback( - (index: number) => { - if (options.length === 1) { + (optionId: string) => { + const latestOptions = optionsRef.current; + if (latestOptions.length === 1) { return; } pollComposer.updateFields({ - options: options.filter((_, i) => i !== index), + options: latestOptions.filter((option) => option.id !== optionId), }); }, - [options, pollComposer], + [pollComposer], + ); + + const onOptionLayout = useCallback( + (optionId: string, height: number) => { + if (!Number.isFinite(height) || height <= 0) { + return; + } + + const { inverseIndexCache, positionCache } = currentOptionPositions.value; + const currentPosition = positionCache[optionId]; + if (currentPosition && Math.abs(currentPosition.updatedHeight - height) < 1) { + return; + } + + const latestOptions = optionsRef.current; + const latestOptionIds = latestOptions.map((option) => option.id); + const isKnownOption = latestOptionIds.includes(optionId); + if (!isKnownOption) { + return; + } + + const nextInverseIndexCache = reconcileInverseIndexCacheWithOptionIds( + inverseIndexCache, + latestOptionIds, + ); + + const nextPositionCache: CurrentOptionPositionsCache['positionCache'] = {}; + const sortedIndices = getSortedIndices(nextInverseIndexCache); + sortedIndices.forEach((index) => { + const currentOptionId = nextInverseIndexCache[index]; + if (!currentOptionId) { + return; + } + const cachedPosition = positionCache[currentOptionId]; + nextPositionCache[currentOptionId] = cachedPosition + ? cachedPosition + : { + updatedHeight: 0, + updatedIndex: index, + updatedTop: 0, + }; + }); + + nextPositionCache[optionId] = { + ...nextPositionCache[optionId], + updatedHeight: height, + }; + + const recalculated = recalculatePositionCache( + nextInverseIndexCache, + nextPositionCache, + normalizedCreatePollOptionGap, + ); + + currentOptionPositions.value = { + inverseIndexCache: nextInverseIndexCache, + positionCache: recalculated.positionCache, + totalHeight: recalculated.totalHeight, + }; + }, + [currentOptionPositions, normalizedCreatePollOptionGap], ); return ( @@ -372,18 +710,19 @@ export const CreatePollOptions = ({ currentOptionPositions }: CreatePollOptionsP {options.map((option, index) => ( ))} @@ -444,3 +783,5 @@ const useStyles = () => { }); }, [semantics]); }; + +const LayoutTransition = LinearTransition.duration(200).springify(); diff --git a/package/src/components/Poll/components/MultipleAnswersField.tsx b/package/src/components/Poll/components/MultipleAnswersField.tsx index b50894bef0..5c3809f57c 100644 --- a/package/src/components/Poll/components/MultipleAnswersField.tsx +++ b/package/src/components/Poll/components/MultipleAnswersField.tsx @@ -122,9 +122,7 @@ const useStyles = () => { optionCardWrapper: { gap: primitives.spacingMd, }, - optionCardSwitch: { - marginRight: primitives.spacingMd, - }, + optionCardSwitch: {}, }); }, [semantics]); }; diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 1712f056cc..6aec0b20bb 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -277,6 +277,10 @@ export type InputMessageInputContextValue = { * Override the entire content of the CreatePoll component. The component has full access to the useCreatePollContext() hook. * */ CreatePollContent?: React.ComponentType; + /** + * Vertical gap between poll options in poll creation dialog. + */ + createPollOptionGap?: number; /** * Override file upload request diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index e41127b85d..94b33a14e6 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -29,6 +29,7 @@ export const useCreateMessageInputContext = ({ compressImageQuality, CooldownTimer, CreatePollContent, + createPollOptionGap, editMessage, FileAttachmentUploadPreview, FileUploadInProgressIndicator, @@ -100,6 +101,7 @@ export const useCreateMessageInputContext = ({ compressImageQuality, CooldownTimer, CreatePollContent, + createPollOptionGap, editMessage, FileAttachmentUploadPreview, FileUploadInProgressIndicator, diff --git a/package/src/contexts/pollContext/createPollContentContext.tsx b/package/src/contexts/pollContext/createPollContentContext.tsx index 7fd69fafe2..127a900bda 100644 --- a/package/src/contexts/pollContext/createPollContentContext.tsx +++ b/package/src/contexts/pollContext/createPollContentContext.tsx @@ -8,13 +8,13 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type CreatePollContentContextValue = { createAndSendPoll: () => Promise; sendMessage: MessageInputContextValue['sendMessage']; + closePollCreationDialog?: () => void; /** - * A property that defines the constant height of the options within the poll creation screen. + * Vertical gap between poll options in the poll creation screen. * - * **Default: ** 71 + * **Default: ** 8 */ - closePollCreationDialog?: () => void; - createPollOptionHeight?: number; + createPollOptionGap?: number; }; export const CreatePollContentContext = React.createContext(