From 97b06ccaf69a0787ee7d74ec4984e2f921497fc0 Mon Sep 17 00:00:00 2001 From: Tanmay Thole <72058456+tanmaythole@users.noreply.github.com> Date: Tue, 26 Mar 2024 18:16:09 +0530 Subject: [PATCH] feat: Scroll to bottom element (#7851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Scroll to bottom element * fix: Scroll to bottom element message * refactor: Removed unnecessary code * refactor: code quality improved * Update app/components/post_list/scroll_to_end_view.tsx Co-authored-by: Elias Nahum * ci: update i18n --------- Co-authored-by: Daniel Espino GarcĂ­a Co-authored-by: Elias Nahum --- app/components/post_list/post_list.tsx | 32 ++++- .../post_list/scroll_to_end_view.tsx | 129 ++++++++++++++++++ assets/base/i18n/en.json | 2 + 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 app/components/post_list/scroll_to_end_view.tsx diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx index 4bbb7b3d7..c2ecd002e 100644 --- a/app/components/post_list/post_list.tsx +++ b/app/components/post_list/post_list.tsx @@ -3,7 +3,7 @@ import {FlatList} from '@stream-io/flat-list-mvcp'; import React, {type ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {DeviceEventEmitter, type ListRenderItemInfo, Platform, type StyleProp, StyleSheet, type ViewStyle} from 'react-native'; +import {DeviceEventEmitter, type ListRenderItemInfo, Platform, type StyleProp, StyleSheet, type ViewStyle, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native'; import Animated, {type AnimatedStyle} from 'react-native-reanimated'; import {fetchPosts, fetchPostThread} from '@actions/remote/post'; @@ -19,6 +19,7 @@ import {getDateForDateLine, preparePostList} from '@utils/post_list'; import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config'; import MoreMessages from './more_messages'; +import ScrollToEndView from './scroll_to_end_view'; import type {PostListItem, PostListOtherItem, ViewableItemsChanged, ViewableItemsChangedListenerEvent} from '@typings/components/post_list'; import type PostModel from '@typings/database/models/servers/post'; @@ -61,6 +62,8 @@ type ScrollIndexFailed = { averageItemLength: number; }; +const CONTENT_OFFSET_THRESHOLD = 160; + const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); const keyExtractor = (item: PostListItem | PostListOtherItem) => (item.type === 'post' ? item.value.currentPost.id : item.value); @@ -106,6 +109,8 @@ const PostList = ({ const onViewableItemsChangedListener = useRef(); const scrolledToHighlighted = useRef(false); const [refreshing, setRefreshing] = useState(false); + const [showScrollToEndBtn, setShowScrollToEndBtn] = useState(false); + const [lastPostId, setLastPostId] = useState(posts[0].id); const theme = useTheme(); const serverUrl = useServerUrl(); const orderedPosts = useMemo(() => { @@ -116,6 +121,10 @@ const PostList = ({ return orderedPosts.findIndex((i) => i.type === 'start-of-new-messages'); }, [orderedPosts]); + const isNewMessage = useMemo(() => { + return posts[0].id !== lastPostId; + }, [posts[0].id, lastPostId]); + useEffect(() => { const t = setTimeout(() => { listRef.current?.scrollToOffset({offset: 0, animated: true}); @@ -158,6 +167,14 @@ const PostList = ({ setRefreshing(false); }, [channelId, location, posts, rootId]); + const onScroll = useCallback((event: NativeSyntheticEvent) => { + const {y} = event.nativeEvent.contentOffset; + if (y === 0) { + setLastPostId(posts[0].id); + } + setShowScrollToEndBtn(y > CONTENT_OFFSET_THRESHOLD); + }, [posts[0].id]); + const onScrollToIndexFailed = useCallback((info: ScrollIndexFailed) => { const index = Math.min(info.highestMeasuredFrameIndex, info.index); @@ -283,6 +300,10 @@ const PostList = ({ }); }, []); + const onScrollToEnd = useCallback(() => { + listRef.current?.scrollToOffset({offset: 0, animated: true}); + }, []); + useEffect(() => { const t = setTimeout(() => { if (highlightedId && orderedPosts && !scrolledToHighlighted.current) { @@ -319,6 +340,7 @@ const PostList = ({ nativeID={nativeID} onEndReached={onEndReached} onEndReachedThreshold={0.9} + onScroll={onScroll} onScrollToIndexFailed={onScrollToIndexFailed} onViewableItemsChanged={onViewableItemsChanged} ref={listRef} @@ -332,6 +354,14 @@ const PostList = ({ refreshing={refreshing} onRefresh={disablePullToRefresh ? undefined : onRefresh} /> + {location !== Screens.PERMALINK && + + } {showMoreMessages && { + const commonButtonStyle: ViewStyle = { + height: 40, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }; + return { + buttonStyle: { + position: 'absolute', + alignSelf: 'center', + bottom: -70, + flexDirection: 'row', + elevation: 4, + shadowOpacity: 0.2, + shadowOffset: {width: 0, height: 4}, + shadowRadius: 4, + }, + scrollToEndButton: { + ...commonButtonStyle, + width: 40, + borderRadius: 32, + backgroundColor: theme.centerChannelBg, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderWidth: 1, + }, + scrollToEndBadge: { + ...commonButtonStyle, + borderRadius: 8, + paddingHorizontal: 12, + backgroundColor: theme.buttonBg, + }, + newMessagesText: { + color: theme.buttonColor, + paddingHorizontal: 8, + overflow: 'hidden', + ...typography('Body', 200, 'SemiBold'), + }, + }; +}); + +type Props = { + onPress: () => void; + isNewMessage: boolean; + showScrollToEndBtn: boolean; + location: string; +}; + +const ScrollToEndView = ({ + onPress, + isNewMessage, + showScrollToEndBtn, + location, +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const isTablet = useIsTablet(); + const styles = getStyleFromTheme(theme); + + // On iOS we have to take account of the keyboard. + // We cannot use `useKeyboardOverlap` here because of the positioning of the element. + const guidingViewRef = useRef(null); + const keyboardHeight = useKeyboardHeight(); + const viewPosition = useViewPosition(guidingViewRef, []); + const dimensions = useWindowDimensions(); + const bottomSpace = (dimensions.height - viewPosition); + const keyboardOverlap = Platform.select({ios: Math.max(0, keyboardHeight - bottomSpace), default: 0}); + + // Thread view on iPads has to take into account the insets + const insets = useSafeAreaInsets(); + const shouldAdjustBottom = (Platform.OS === 'ios') && isTablet && (location === Screens.THREAD) && !keyboardHeight; + const bottomAdjustment = shouldAdjustBottom ? insets.bottom : 0; + + const message = location === Screens.THREAD ? + intl.formatMessage({id: 'postList.scrollToBottom.newReplies', defaultMessage: 'New replies'}) : + intl.formatMessage({id: 'postList.scrollToBottom.newMessages', defaultMessage: 'New messages'}); + + const animatedStyle = useAnimatedStyle( + () => ({ + transform: [ + { + translateY: withTiming(showScrollToEndBtn ? -80 - keyboardOverlap - bottomAdjustment : 0, {duration: 300}), + }, + ], + maxWidth: withTiming(isNewMessage ? 169 : 40, {duration: 300}), + }), + [showScrollToEndBtn, isNewMessage, keyboardOverlap, bottomAdjustment], + ); + + const scrollButtonStyles = isNewMessage ? styles.scrollToEndBadge : styles.scrollToEndButton; + + return ( + + + + + {isNewMessage && ( + {message} + )} + + + + ); +}; + +export default React.memo(ScrollToEndView); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 02c231c35..d097253c7 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -896,6 +896,8 @@ "post_priority.picker.title": "Message priority", "post.options.title": "Options", "post.reactions.title": "Reactions", + "postList.scrollToBottom.newMessages": "New messages", + "postList.scrollToBottom.newReplies": "New replies", "posts_view.newMsg": "New Messages", "public_link_copied": "Link copied to clipboard", "rate.button.needs_work": "Needs work",