feat: Scroll to bottom element (#7851)

* 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 <nahumhbl@gmail.com>

* ci: update i18n

---------

Co-authored-by: Daniel Espino García <larkox@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Tanmay Thole 2024-03-26 18:16:09 +05:30 committed by GitHub
parent 0c31d22ec7
commit 97b06ccaf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 162 additions and 1 deletions

View file

@ -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<ViewableItemsChangedListenerEvent>();
const scrolledToHighlighted = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const [showScrollToEndBtn, setShowScrollToEndBtn] = useState(false);
const [lastPostId, setLastPostId] = useState<string>(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<NativeScrollEvent>) => {
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 &&
<ScrollToEndView
onPress={onScrollToEnd}
isNewMessage={isNewMessage}
showScrollToEndBtn={showScrollToEndBtn}
location={location}
/>
}
{showMoreMessages &&
<MoreMessages
channelId={channelId}

View file

@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef} from 'react';
import {useIntl} from 'react-intl';
import {Platform, Pressable, Text, useWindowDimensions, View, type ViewStyle} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet, useKeyboardHeight, useViewPosition} from '@hooks/device';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
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<View>(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 (
<View ref={guidingViewRef}>
<Animated.View style={[animatedStyle, styles.buttonStyle]}>
<Pressable
onPress={onPress}
style={scrollButtonStyles}
>
<CompassIcon
size={18}
name='arrow-down'
color={isNewMessage ? theme.buttonColor : changeOpacity(theme.centerChannelColor, 0.56)}
/>
{isNewMessage && (
<Text style={styles.newMessagesText}>{message}</Text>
)}
</Pressable>
</Animated.View>
</View>
);
};
export default React.memo(ScrollToEndView);

View file

@ -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",