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:
parent
0c31d22ec7
commit
97b06ccaf6
3 changed files with 162 additions and 1 deletions
|
|
@ -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}
|
||||
|
|
|
|||
129
app/components/post_list/scroll_to_end_view.tsx
Normal file
129
app/components/post_list/scroll_to_end_view.tsx
Normal 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);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue