From a81b56212e4dc3e1b623c77d17cdb1c63b922e76 Mon Sep 17 00:00:00 2001 From: Anurag Shivarathri Date: Thu, 7 Oct 2021 15:14:34 +0530 Subject: [PATCH] MM-38784, MM-38409 New messages line in threads screen & Pull to refresh for global threads screen (#5690) * New messages line in threads & Pull to refresh for global threads * Updated snapshot * Using postlist's RefreshControl component * Updated threadlist snapshot * Reverting snapshot * Updated Snapshots * Snapshots updated * Added 'thread last viewed at' to handle new messages line correctly * Lint fix * Updated snapshots * Reverted comparision check * Remove unused code * Batching actions * Do not add new message line for self messages Co-authored-by: Mattermod --- app/actions/views/threads.ts | 10 ++ app/actions/websocket/threads.ts | 46 ++++-- .../global_threads/global_threads.tsx | 13 ++ .../__snapshots__/index.test.tsx.snap | 143 +++++++++++------- .../global_threads/thread_list/index.test.tsx | 2 + .../global_threads/thread_list/index.tsx | 59 +++++--- .../post_list/post_list_refresh_control.tsx | 9 +- app/constants/view.js | 2 + app/mm-redux/actions/posts.ts | 6 +- app/mm-redux/actions/threads.ts | 44 +++--- app/mm-redux/utils/post_list.ts | 3 +- app/reducers/views/threads.ts | 12 ++ .../__snapshots__/thread.ios.test.js.snap | 6 - app/screens/thread/index.js | 14 +- app/screens/thread/thread.android.js | 3 +- app/screens/thread/thread.ios.js | 3 +- app/screens/thread/thread_base.js | 59 +++++--- app/selectors/threads.ts | 14 +- app/store/initial_state.js | 1 + 19 files changed, 300 insertions(+), 149 deletions(-) diff --git a/app/actions/views/threads.ts b/app/actions/views/threads.ts index 16430046c..64c931b89 100644 --- a/app/actions/views/threads.ts +++ b/app/actions/views/threads.ts @@ -2,6 +2,16 @@ // See LICENSE.txt for license information. import {ViewTypes} from '@constants'; +export function updateThreadLastViewedAt(threadId: string, lastViewedAt: number) { + return { + type: ViewTypes.THREAD_LAST_VIEWED_AT, + data: { + threadId, + lastViewedAt, + }, + }; +} + export const handleViewingGlobalThreadsScreen = () => ( { type: ViewTypes.VIEWING_GLOBAL_THREADS_SCREEN, diff --git a/app/actions/websocket/threads.ts b/app/actions/websocket/threads.ts index f9791ad26..e7f84d4ed 100644 --- a/app/actions/websocket/threads.ts +++ b/app/actions/websocket/threads.ts @@ -1,10 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {batchActions} from 'redux-batched-actions'; + +import {updateThreadLastViewedAt} from '@actions/views/threads'; import {handleThreadArrived, handleReadChanged, handleAllMarkedRead, handleFollowChanged, getThread as fetchThread} from '@mm-redux/actions/threads'; import {getCurrentUserId} from '@mm-redux/selectors/entities/common'; +import {getSelectedPost} from '@mm-redux/selectors/entities/posts'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; import {getThread} from '@mm-redux/selectors/entities/threads'; -import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; +import {ActionResult, DispatchFunc, GenericAction, GetStateFunc} from '@mm-redux/types/actions'; import {WebSocketMessage} from '@mm-redux/types/websocket'; export function handleThreadUpdated(msg: WebSocketMessage) { @@ -27,21 +31,31 @@ export function handleThreadReadChanged(msg: WebSocketMessage) { const thread = getThread(state, msg.data.thread_id); // Mark only following threads as read. - if (thread?.is_following) { - dispatch( - handleReadChanged( - msg.data.thread_id, - msg.broadcast.team_id, - msg.data.channel_id, - { - lastViewedAt: msg.data.timestamp, - prevUnreadMentions: thread.unread_mentions, - newUnreadMentions: msg.data.unread_mentions, - prevUnreadReplies: thread.unread_replies, - newUnreadReplies: msg.data.unread_replies, - }, - ), - ); + if (thread) { + const actions: GenericAction[] = []; + const selectedPost = getSelectedPost(state); + if (selectedPost?.id !== thread.id) { + actions.push(updateThreadLastViewedAt(thread.id, msg.data.timestamp)); + } + if (thread.is_following) { + actions.push( + handleReadChanged( + msg.data.thread_id, + msg.broadcast.team_id, + msg.data.channel_id, + { + lastViewedAt: msg.data.timestamp, + prevUnreadMentions: thread.unread_mentions, + newUnreadMentions: msg.data.unread_mentions, + prevUnreadReplies: thread.unread_replies, + newUnreadReplies: msg.data.unread_replies, + }, + ), + ); + } + if (actions.length) { + dispatch(batchActions(actions)); + } } } else { dispatch(handleAllMarkedRead(msg.broadcast.team_id)); diff --git a/app/components/global_threads/global_threads.tsx b/app/components/global_threads/global_threads.tsx index 86b7f4284..6255054ef 100644 --- a/app/components/global_threads/global_threads.tsx +++ b/app/components/global_threads/global_threads.tsx @@ -38,6 +38,7 @@ function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCo const listRef = React.useRef(null); const [isLoading, setIsLoading] = React.useState(true); + const [isRefreshing, setIsRefreshing] = React.useState(false); const scrollToTop = () => { listRef.current?.scrollToOffset({offset: 0}); @@ -79,6 +80,16 @@ function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCo } }; + const onRefresh = async () => { + if (!isLoading) { + if (!isRefreshing) { + setIsRefreshing(true); + } + await loadThreads('', '', viewingUnreads); + setIsRefreshing(false); + } + }; + const markAllAsRead = () => { Alert.alert( intl.formatMessage({ @@ -112,9 +123,11 @@ function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCo - + + } + ListFooterComponent={null} + contentContainerStyle={ + Object { + "flexGrow": 1, } - isUnreads={true} - /> - } - ListFooterComponent={null} - contentContainerStyle={ - Object { - "flexGrow": 1, } - } - data={ - Array [ - "thread1", - ] - } - initialNumToRender={10} - keyExtractor={[Function]} - numColumns={1} - onEndReached={[Function]} - onEndReachedThreshold={2} - removeClippedSubviews={true} - renderItem={[Function]} - scrollIndicatorInsets={ - Object { - "right": 1, + data={ + Array [ + "thread1", + ] } - } - /> + initialNumToRender={10} + keyExtractor={[Function]} + numColumns={1} + onEndReached={[Function]} + onEndReachedThreshold={2} + onScroll={[Function]} + removeClippedSubviews={true} + renderItem={[Function]} + scrollIndicatorInsets={ + Object { + "right": 1, + } + } + /> + `; diff --git a/app/components/global_threads/thread_list/index.test.tsx b/app/components/global_threads/thread_list/index.test.tsx index ba134be53..478c1f1cb 100644 --- a/app/components/global_threads/thread_list/index.test.tsx +++ b/app/components/global_threads/thread_list/index.test.tsx @@ -25,9 +25,11 @@ describe('Global Thread List', () => { haveUnreads: true, intl, isLoading: false, + isRefreshing: false, listRef: React.useRef(null), loadMoreThreads: jest.fn(), markAllAsRead, + onRefresh: jest.fn(), testID, theme: Preferences.THEMES.denim, threadIds: ['thread1'], diff --git a/app/components/global_threads/thread_list/index.tsx b/app/components/global_threads/thread_list/index.tsx index 41d83180b..a3329ffaa 100644 --- a/app/components/global_threads/thread_list/index.tsx +++ b/app/components/global_threads/thread_list/index.tsx @@ -2,12 +2,13 @@ // See LICENSE.txt for license information. import React from 'react'; import {injectIntl, intlShape} from 'react-intl'; -import {FlatList, Platform, View} from 'react-native'; +import {FlatList, NativeSyntheticEvent, NativeScrollEvent, Platform, View} from 'react-native'; import EmptyState from '@components/global_threads/empty_state'; import ThreadItem from '@components/global_threads/thread_item'; import Loading from '@components/loading'; import {INITIAL_BATCH_TO_RENDER} from '@components/post_list/post_list_config'; +import CustomRefreshControl from '@components/post_list/post_list_refresh_control'; import {ViewTypes} from '@constants'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -21,9 +22,11 @@ export type Props = { haveUnreads: boolean; intl: typeof intlShape; isLoading: boolean; + isRefreshing: boolean; loadMoreThreads: () => Promise; listRef: React.RefObject; markAllAsRead: () => void; + onRefresh: () => void; testID: string; theme: Theme; threadIds: $ID[]; @@ -32,9 +35,11 @@ export type Props = { viewingUnreads: boolean; }; -function ThreadList({haveUnreads, intl, isLoading, loadMoreThreads, listRef, markAllAsRead, testID, theme, threadIds, viewAllThreads, viewUnreadThreads, viewingUnreads}: Props) { +function ThreadList({haveUnreads, intl, isLoading, isRefreshing, loadMoreThreads, listRef, markAllAsRead, onRefresh, testID, theme, threadIds, viewAllThreads, viewUnreadThreads, viewingUnreads}: Props) { const style = getStyleSheet(theme); + const [offsetY, setOffsetY] = React.useState(0); + const handleEndReached = React.useCallback(() => { loadMoreThreads(); }, [loadMoreThreads, viewingUnreads]); @@ -51,6 +56,17 @@ function ThreadList({haveUnreads, intl, isLoading, loadMoreThreads, listRef, mar ); }, [theme]); + const onScroll = React.useCallback((event: NativeSyntheticEvent) => { + if (Platform.OS === 'android') { + const {y} = event.nativeEvent.contentOffset; + if (y === 0) { + setOffsetY(y); + } else if (offsetY === 0 && y !== 0) { + setOffsetY(y); + } + } + }, [offsetY]); + const renderHeader = () => { if (!viewingUnreads && !threadIds.length) { return null; @@ -96,21 +112,30 @@ function ThreadList({haveUnreads, intl, isLoading, loadMoreThreads, listRef, mar return ( {renderHeader()} - + + + ); } diff --git a/app/components/post_list/post_list_refresh_control.tsx b/app/components/post_list/post_list_refresh_control.tsx index cf6d47415..58aedbe22 100644 --- a/app/components/post_list/post_list_refresh_control.tsx +++ b/app/components/post_list/post_list_refresh_control.tsx @@ -9,6 +9,7 @@ import type {Theme} from '@mm-redux/types/theme'; type Props = { children: ReactElement; enabled: boolean; + isInverted?: boolean; onRefresh: () => void; refreshing: boolean; theme: Theme; @@ -17,11 +18,13 @@ type Props = { const style = StyleSheet.create({ container: { flex: 1, + }, + containerInverse: { scaleY: -1, }, }); -const PostListRefreshControl = ({children, enabled, onRefresh, refreshing, theme}: Props) => { +const PostListRefreshControl = ({children, enabled, isInverted = true, onRefresh, refreshing, theme}: Props) => { const props = { colors: [theme.onlineIndicator, theme.awayIndicator, theme.dndIndicator], onRefresh, @@ -34,7 +37,7 @@ const PostListRefreshControl = ({children, enabled, onRefresh, refreshing, theme {children} @@ -45,7 +48,7 @@ const PostListRefreshControl = ({children, enabled, onRefresh, refreshing, theme return React.cloneElement( children, - {refreshControl, inverted: true}, + {refreshControl, inverted: isInverted}, ); }; diff --git a/app/constants/view.js b/app/constants/view.js index 85e877498..eed6e72a7 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -105,6 +105,8 @@ const ViewTypes = keyMirror({ VIEWING_GLOBAL_THREADS_UNREADS: null, VIEWING_GLOBAL_THREADS_ALL: null, + + THREAD_LAST_VIEWED_AT: null, }); const RequiredServer = { diff --git a/app/mm-redux/actions/posts.ts b/app/mm-redux/actions/posts.ts index ceb1b93ed..49da80192 100644 --- a/app/mm-redux/actions/posts.ts +++ b/app/mm-redux/actions/posts.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. /* eslint-disable max-lines */ +import {updateThreadLastViewedAt} from '@actions/views/threads'; import {Client4} from '@client/rest'; import {WebsocketEvents} from '@constants'; import {THREAD} from '@constants/screen'; @@ -447,7 +448,10 @@ export function setUnreadPost(userId: string, postId: string, location: string) if (isUnreadFromThreadScreen) { const currentTeamId = getThreadTeamId(state, postId); const threadId = post.root_id || post.id; - dispatch(handleFollowChanged(threadId, currentTeamId, true)); + const actions: GenericAction[] = []; + actions.push(handleFollowChanged(threadId, currentTeamId, true)); + actions.push(updateThreadLastViewedAt(threadId, post.create_at)); + dispatch(batchActions(actions)); await dispatch(updateThreadRead(userId, threadId, post.create_at)); return {data: true}; } diff --git a/app/mm-redux/actions/threads.ts b/app/mm-redux/actions/threads.ts index 2e9e5b333..5d4b7bb6f 100644 --- a/app/mm-redux/actions/threads.ts +++ b/app/mm-redux/actions/threads.ts @@ -215,16 +215,13 @@ export function updateThreadRead(userId: string, threadId: string, timestamp: nu } export function handleFollowChanged(threadId: string, teamId: string, following: boolean) { - return (dispatch: DispatchFunc) => { - dispatch({ - type: ThreadTypes.FOLLOW_CHANGED_THREAD, - data: { - id: threadId, - team_id: teamId, - following, - }, - }); - return {data: true}; + return { + type: ThreadTypes.FOLLOW_CHANGED_THREAD, + data: { + id: threadId, + team_id: teamId, + following, + }, }; } @@ -270,20 +267,17 @@ export function handleReadChanged( newUnreadReplies: number; }, ) { - return (dispatch: DispatchFunc) => { - dispatch({ - type: ThreadTypes.READ_CHANGED_THREAD, - data: { - id: threadId, - teamId, - channelId, - lastViewedAt, - prevUnreadMentions, - newUnreadMentions, - prevUnreadReplies, - newUnreadReplies, - }, - }); - return {data: true}; + return { + type: ThreadTypes.READ_CHANGED_THREAD, + data: { + id: threadId, + teamId, + channelId, + lastViewedAt, + prevUnreadMentions, + newUnreadMentions, + prevUnreadReplies, + newUnreadReplies, + }, }; } diff --git a/app/mm-redux/utils/post_list.ts b/app/mm-redux/utils/post_list.ts index 5372388b4..2c8c7c81d 100644 --- a/app/mm-redux/utils/post_list.ts +++ b/app/mm-redux/utils/post_list.ts @@ -99,7 +99,8 @@ function selectOrderedPostIds(posts: types.posts.Post[], lastViewedAt: number, i lastViewedAt && post.create_at > lastViewedAt && !addedNewMessagesIndicator && - indicateNewMessages + indicateNewMessages && + currentUser.id !== post.user_id ) { out.push(START_OF_NEW_MESSAGES); addedNewMessagesIndicator = true; diff --git a/app/reducers/views/threads.ts b/app/reducers/views/threads.ts index fd1678c56..ca8d6dd55 100644 --- a/app/reducers/views/threads.ts +++ b/app/reducers/views/threads.ts @@ -5,6 +5,17 @@ import {combineReducers} from 'redux'; import {ViewTypes} from '@constants'; import {GenericAction} from '@mm-redux/types/actions'; +export const lastViewedAt = (state: Record = {}, action: GenericAction) => { + switch (action.type) { + case ViewTypes.THREAD_LAST_VIEWED_AT: + return { + ...state, + [action.data.threadId]: action.data.lastViewedAt, + }; + } + return state; +}; + const viewingGlobalThreads = (state = false, action: GenericAction) => { switch (action.type) { case ViewTypes.VIEWING_GLOBAL_THREADS_SCREEN: { @@ -30,6 +41,7 @@ const viewingGlobalThreadsUnreads = (state = false, action: GenericAction) => { }; export default combineReducers({ + lastViewedAt, viewingGlobalThreads, viewingGlobalThreadsUnreads, }); diff --git a/app/screens/thread/__snapshots__/thread.ios.test.js.snap b/app/screens/thread/__snapshots__/thread.ios.test.js.snap index 78892087e..8d8872ea6 100644 --- a/app/screens/thread/__snapshots__/thread.ios.test.js.snap +++ b/app/screens/thread/__snapshots__/thread.ios.test.js.snap @@ -27,8 +27,6 @@ exports[`thread should match snapshot, has root post 1`] = ` > { - const [padding, duration] = visible ? - [TYPING_HEIGHT, 200] : - [0, 400]; + const [padding, duration] = visible ? [TYPING_HEIGHT, 200] : [0, 400]; return Animated.timing(this.bottomPadding, { toValue: padding, diff --git a/app/selectors/threads.ts b/app/selectors/threads.ts index a835cda02..00140f9b2 100644 --- a/app/selectors/threads.ts +++ b/app/selectors/threads.ts @@ -1,6 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {GlobalState} from '@mm-redux/types/store'; +import {getThread} from '@mm-redux/selectors/entities/threads'; + +import type {GlobalState} from '@mm-redux/types/store'; +import type {UserThread} from '@mm-redux/types/threads'; +import type {$ID} from '@mm-redux/types/utilities'; + +export function getThreadLastViewedAt(state: GlobalState, threadId: $ID): number { + if (state.views.threads.lastViewedAt[threadId]) { + // timestamp - 1, to properly mark "new messages" in threads when manually unread as lastViewedAt is set to the post's created timestamp + return state.views.threads.lastViewedAt[threadId] - 1; + } + return getThread(state, threadId)?.last_viewed_at || 0; +} export function getViewingGlobalThreads(state: GlobalState): boolean { return state.views.threads.viewingGlobalThreads; diff --git a/app/store/initial_state.js b/app/store/initial_state.js index bcefe882e..8c3a4c4ee 100644 --- a/app/store/initial_state.js +++ b/app/store/initial_state.js @@ -153,6 +153,7 @@ const state = { lastTeamId: '', }, threads: { + lastViewedAt: {}, viewingGlobalThreads: false, viewingGlobalThreadsUnreads: false, },