From 5b446769855d55ef4a7bc7b01a20caaab0b8b16e Mon Sep 17 00:00:00 2001 From: Anurag Shivarathri Date: Thu, 10 Mar 2022 19:15:30 +0530 Subject: [PATCH] [MM-39708] Gekidou Thread Screen (#6015) * Thread screen * misc * Added snapshot for ThreadOverview * Updated snapshot * Misc * Updated snapshot and ts fixes * Made thread as a modal, fixes post list not closing on tablet * Removed unsued variables * Putting back the empty space before the root post (inverse list footer) * Changed input text * Removed empty footer space * Misc fixes * Disables new messages line for thread * Loading threads before opening modal & BottomSheet componentId fix * Moved merge navigation options to switchToThread * Moved LeftButton to switch to thread * Removed Q.and * Misc fixes * Added task id for pagination * Removed useMemo, Q.and * move thread close button as a prop * Remove title font styles to use default * Misc changes * Misc fix Co-authored-by: Mattermod Co-authored-by: Elias Nahum --- app/actions/local/thread.ts | 79 +++++++ app/actions/remote/post.ts | 17 ++ app/actions/remote/thread.ts | 13 ++ .../post_draft/post_input/post_input.tsx | 2 +- .../post_list/date_separator/index.tsx | 5 +- app/components/post_list/index.tsx | 41 +++- .../post_list/post/header/reply/index.tsx | 10 +- app/components/post_list/post/post.tsx | 7 +- .../thread_overview.test.tsx.snap | 212 ++++++++++++++++++ .../post_list/thread_overview/index.ts | 46 ++++ .../thread_overview/thread_overview.test.tsx | 36 +++ .../thread_overview/thread_overview.tsx | 148 ++++++++++++ app/constants/action_type.ts | 1 + app/constants/post_draft.ts | 1 + .../server_data_operator/handlers/post.ts | 15 +- app/screens/bottom_sheet/index.tsx | 25 ++- app/screens/channel/channel.tsx | 18 +- app/screens/index.tsx | 3 + .../post_options/options/reply_option.tsx | 17 +- app/screens/post_options/post_options.tsx | 6 +- app/screens/thread/index.tsx | 30 +++ app/screens/thread/thread.tsx | 94 ++++++++ app/screens/thread/thread_post_list/index.ts | 65 ++++++ .../thread_post_list/thread_post_list.tsx | 75 +++++++ app/utils/post_list/index.ts | 9 + assets/base/i18n/en.json | 7 +- 26 files changed, 926 insertions(+), 56 deletions(-) create mode 100644 app/actions/local/thread.ts create mode 100644 app/actions/remote/thread.ts create mode 100644 app/components/post_list/thread_overview/__snapshots__/thread_overview.test.tsx.snap create mode 100644 app/components/post_list/thread_overview/index.ts create mode 100644 app/components/post_list/thread_overview/thread_overview.test.tsx create mode 100644 app/components/post_list/thread_overview/thread_overview.tsx create mode 100644 app/screens/thread/index.tsx create mode 100644 app/screens/thread/thread.tsx create mode 100644 app/screens/thread/thread_post_list/index.ts create mode 100644 app/screens/thread/thread_post_list/thread_post_list.tsx diff --git a/app/actions/local/thread.ts b/app/actions/local/thread.ts new file mode 100644 index 0000000000..4352032539 --- /dev/null +++ b/app/actions/local/thread.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import CompassIcon from '@components/compass_icon'; +import {General, Screens} from '@constants'; +import DatabaseManager from '@database/manager'; +import {getTranslations, t} from '@i18n'; +import {queryChannelById} from '@queries/servers/channel'; +import {queryPostById} from '@queries/servers/post'; +import {queryCurrentUser} from '@queries/servers/user'; +import {showModal} from '@screens/navigation'; +import EphemeralStore from '@store/ephemeral_store'; +import {changeOpacity} from '@utils/theme'; + +export const switchToThread = async (serverUrl: string, rootId: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + try { + const user = await queryCurrentUser(database); + if (!user) { + return {error: 'User not found'}; + } + + const post = await queryPostById(database, rootId); + if (!post) { + return {error: 'Post not found'}; + } + const channel = await queryChannelById(database, post.channelId); + if (!channel) { + return {error: 'Channel not found'}; + } + + const theme = EphemeralStore.theme; + if (!theme) { + return {error: 'Theme not found'}; + } + + // Get translation by user locale + const translations = getTranslations(user.locale); + + // Get title translation or default title message + let title = translations[t('thread.header.thread')] || 'Thread'; + if (channel.type === General.DM_CHANNEL) { + title = translations[t('thread.header.thread_dm')] || 'Direct Message Thread'; + } + + let subtitle = ''; + if (channel?.type !== General.DM_CHANNEL) { + // Get translation or default message + subtitle = translations[t('thread.header.thread_in')] || 'in {channelName}'; + subtitle = subtitle.replace('{channelName}', channel.displayName); + } + + const closeButtonId = 'close-threads'; + + showModal(Screens.THREAD, '', {closeButtonId, rootId}, { + topBar: { + title: { + text: title, + }, + subtitle: { + color: changeOpacity(theme.sidebarHeaderTextColor, 0.72), + text: subtitle, + }, + leftButtons: [{ + id: closeButtonId, + icon: CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor), + testID: closeButtonId, + }], + }, + }); + return {}; + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index c47c2fddd3..f99f1cd9a9 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -410,6 +410,23 @@ export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOn } }; +export const fetchPostThread = async (serverUrl: string, postId: string, fetchOnly = false): Promise => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const data = await client.getPostThread(postId); + return processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_IN_THREAD, data, fetchOnly); + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + export const postActionWithCookie = async (serverUrl: string, postId: string, actionId: string, actionCookie: string, selectedOption = '') => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts new file mode 100644 index 0000000000..91a241a3b6 --- /dev/null +++ b/app/actions/remote/thread.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {switchToThread} from '@actions/local/thread'; +import {fetchPostThread} from '@actions/remote/post'; + +export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string) => { + // Load thread before we open to the thread modal + // https://mattermost.atlassian.net/browse/MM-42232 + fetchPostThread(serverUrl, rootId); + + switchToThread(serverUrl, rootId); +}; diff --git a/app/components/post_draft/post_input/post_input.tsx b/app/components/post_draft/post_input/post_input.tsx index f508d72c7a..28c76b0fc6 100644 --- a/app/components/post_draft/post_input/post_input.tsx +++ b/app/components/post_draft/post_input/post_input.tsx @@ -68,7 +68,7 @@ const getPlaceHolder = (rootId?: string) => { let placeholder; if (rootId) { - placeholder = {id: t('create_comment.addComment'), defaultMessage: 'Add a comment...'}; + placeholder = {id: t('create_post.thread_reply'), defaultMessage: 'Reply to this thread...'}; } else { placeholder = {id: t('create_post.write'), defaultMessage: 'Write to {channelDisplayName}'}; } diff --git a/app/components/post_list/date_separator/index.tsx b/app/components/post_list/date_separator/index.tsx index 41e31855b6..84cb51c968 100644 --- a/app/components/post_list/date_separator/index.tsx +++ b/app/components/post_list/date_separator/index.tsx @@ -22,17 +22,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { alignItems: 'center', flexDirection: 'row', marginVertical: 8, - paddingHorizontal: 20, }, line: { flex: 1, height: 1, backgroundColor: theme.centerChannelColor, - opacity: 0.2, + opacity: 0.1, }, date: { color: theme.centerChannelColor, - marginHorizontal: 4, + marginHorizontal: 16, ...typography('Body', 75, 'SemiBold'), }, }; diff --git a/app/components/post_list/index.tsx b/app/components/post_list/index.tsx index 41b3b8477d..c1d7eb0f51 100644 --- a/app/components/post_list/index.tsx +++ b/app/components/post_list/index.tsx @@ -6,15 +6,16 @@ import React, {ReactElement, useCallback, useEffect, useMemo, useRef, useState} import {DeviceEventEmitter, NativeScrollEvent, NativeSyntheticEvent, Platform, StyleProp, StyleSheet, ViewStyle, ViewToken} from 'react-native'; import Animated from 'react-native-reanimated'; -import {fetchPosts} from '@actions/remote/post'; +import {fetchPosts, fetchPostThread} from '@actions/remote/post'; import CombinedUserActivity from '@components/post_list/combined_user_activity'; import DateSeparator from '@components/post_list/date_separator'; import NewMessagesLine from '@components/post_list/new_message_line'; import Post from '@components/post_list/post'; +import ThreadOverview from '@components/post_list/thread_overview'; import {Events, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList, START_OF_NEW_MESSAGES} from '@utils/post_list'; +import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, isThreadOverview, preparePostList, START_OF_NEW_MESSAGES} from '@utils/post_list'; import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config'; import MoreMessages from './more_messages'; @@ -138,7 +139,7 @@ const PostList = ({ if (location === Screens.CHANNEL && channelId) { await fetchPosts(serverUrl, channelId); } else if (location === Screens.THREAD && rootId) { - // await getPostThread(rootId); + await fetchPostThread(serverUrl, rootId); } setRefreshing(false); }, [channelId, location, rootId]); @@ -228,6 +229,13 @@ const PostList = ({ timezone={isTimezoneEnabled ? currentTimezone : null} /> ); + } else if (isThreadOverview(item)) { + return ( + + ); } if (isCombinedUserActivityPost(item)) { @@ -246,9 +254,23 @@ const PostList = ({ let previousPost: PostModel|undefined; let nextPost: PostModel|undefined; - const prev = orderedPosts.slice(index + 1).find((v) => typeof v !== 'string'); - if (prev) { - previousPost = prev as PostModel; + + const lastPosts = orderedPosts.slice(index + 1); + const immediateLastPost = lastPosts[0]; + + // Post after `Thread Overview` should show user avatar irrespective of being the consecutive post + // So we skip sending previous post to avoid the check for consecutive post + const skipFindingPreviousPost = ( + location === Screens.THREAD && + typeof immediateLastPost === 'string' && + isThreadOverview(immediateLastPost) + ); + + if (!skipFindingPreviousPost) { + const prev = lastPosts.find((v) => typeof v !== 'string'); + if (prev) { + previousPost = prev as PostModel; + } } if (index > 0) { @@ -262,12 +284,19 @@ const PostList = ({ } } + // Skip rendering Flag for the root post in the thread as it is visible in the `Thread Overview` + const skipFlaggedHeader = ( + location === Screens.THREAD && + item.id === rootId + ); + const postProps = { highlightPinnedOrSaved, location, nextPost, previousPost, shouldRenderReplyButton, + skipFlaggedHeader, }; return ( diff --git a/app/components/post_list/post/header/reply/index.tsx b/app/components/post_list/post/header/reply/index.tsx index e06e16c272..a10df239fd 100644 --- a/app/components/post_list/post/header/reply/index.tsx +++ b/app/components/post_list/post/header/reply/index.tsx @@ -5,9 +5,10 @@ import React, {useCallback} from 'react'; import {Text, View} from 'react-native'; import {TouchableOpacity} from 'react-native-gesture-handler'; +import {fetchAndSwitchToThread} from '@actions/remote/thread'; import CompassIcon from '@components/compass_icon'; import {SEARCH} from '@constants/screens'; -import {goToScreen} from '@screens/navigation'; +import {useServerUrl} from '@context/server'; import {preventDoubleTap} from '@utils/tap'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -46,11 +47,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) => { const style = getStyleSheet(theme); + const serverUrl = useServerUrl(); const onPress = useCallback(preventDoubleTap(() => { - // https://mattermost.atlassian.net/browse/MM-39708 - goToScreen('THREADS_SCREEN_NOT_IMPLEMENTED_YET', '', {post}); - }), []); + const rootId = post.rootId || post.id; + fetchAndSwitchToThread(serverUrl, rootId); + }), [serverUrl]); return ( 0)) { removePost(serverUrl, post); diff --git a/app/components/post_list/thread_overview/__snapshots__/thread_overview.test.tsx.snap b/app/components/post_list/thread_overview/__snapshots__/thread_overview.test.tsx.snap new file mode 100644 index 0000000000..2142dbf5cd --- /dev/null +++ b/app/components/post_list/thread_overview/__snapshots__/thread_overview.test.tsx.snap @@ -0,0 +1,212 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ThreadOverview should match snapshot when post is not saved and 0 replies 1`] = ` + + + + No replies yet + + + + + + + + + + + + + + + +`; + +exports[`ThreadOverview should match snapshot when post is saved and has replies 1`] = ` + + + + 2 replies + + + + + + + + + + + + + + + +`; diff --git a/app/components/post_list/thread_overview/index.ts b/app/components/post_list/thread_overview/index.ts new file mode 100644 index 0000000000..5ae2bf418b --- /dev/null +++ b/app/components/post_list/thread_overview/index.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Preferences} from '@constants'; +import {MM_TABLES} from '@constants/database'; + +import ThreadOverview from './thread_overview'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; +import type PreferenceModel from '@typings/database/models/servers/preference'; + +const {SERVER: {POST, PREFERENCE}} = MM_TABLES; + +const enhanced = withObservables( + ['rootId'], + ({database, rootId}: WithDatabaseArgs & {rootId: string}) => { + return { + rootPost: database.get(POST).query( + Q.where('id', rootId), + ).observe().pipe( + + // Root post might not have loaded while the thread screen is opening + switchMap((posts) => posts[0]?.observe() || of$(undefined)), + ), + isSaved: database. + get(PREFERENCE). + query(Q.where('category', Preferences.CATEGORY_SAVED_POST), Q.where('name', rootId)). + observe(). + pipe( + switchMap((pref) => of$(Boolean(pref[0]?.value === 'true'))), + ), + repliesCount: database.get(POST).query( + Q.where('root_id', rootId), + Q.where('delete_at', Q.eq(0)), + ).observeCount(), + }; + }); + +export default withDatabase(enhanced(ThreadOverview)); diff --git a/app/components/post_list/thread_overview/thread_overview.test.tsx b/app/components/post_list/thread_overview/thread_overview.test.tsx new file mode 100644 index 0000000000..4ebceffa40 --- /dev/null +++ b/app/components/post_list/thread_overview/thread_overview.test.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithIntl} from '@test/intl-test-helper'; + +import ThreadOverview from './thread_overview'; + +import type PostModel from '@typings/database/models/servers/post'; + +describe('ThreadOverview', () => { + it('should match snapshot when post is not saved and 0 replies', () => { + const props = { + isSaved: true, + repliesCount: 0, + rootPost: {} as PostModel, + testID: 'thread-overview', + }; + + const wrapper = renderWithIntl(); + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot when post is saved and has replies', () => { + const props = { + isSaved: false, + repliesCount: 2, + rootPost: {} as PostModel, + testID: 'thread-overview', + }; + + const wrapper = renderWithIntl(); + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/post_list/thread_overview/thread_overview.tsx b/app/components/post_list/thread_overview/thread_overview.tsx new file mode 100644 index 0000000000..54d9e87b0d --- /dev/null +++ b/app/components/post_list/thread_overview/thread_overview.tsx @@ -0,0 +1,148 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {Keyboard, Platform, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; + +import {deleteSavedPost, savePostPreference} from '@actions/remote/preference'; +import FormattedText from '@app/components/formatted_text'; +import {Screens} from '@app/constants'; +import {preventDoubleTap} from '@app/utils/tap'; +import CompassIcon from '@components/compass_icon'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + isSaved: boolean; + repliesCount: number; + rootPost?: PostModel; + testID: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + borderTopWidth: 1, + borderBottomWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.1), + flexDirection: 'row', + marginVertical: 12, + paddingHorizontal: 20, + paddingVertical: 10, + }, + repliesCountContainer: { + flex: 1, + }, + repliesCount: { + color: changeOpacity(theme.centerChannelColor, 0.64), + marginHorizontal: 4, + ...typography('Body', 200, 'Regular'), + }, + optionsContainer: { + flexDirection: 'row', + }, + optionContainer: { + marginLeft: 16, + }, + }; +}); + +const ThreadOverview = ({isSaved, repliesCount, rootPost, testID}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const intl = useIntl(); + const isTablet = useIsTablet(); + const serverUrl = useServerUrl(); + + const onHandleSavePress = useCallback(preventDoubleTap(() => { + if (rootPost?.id) { + const remoteAction = isSaved ? deleteSavedPost : savePostPreference; + remoteAction(serverUrl, rootPost.id); + } + }), [isSaved, rootPost, serverUrl]); + + const showPostOptions = useCallback(preventDoubleTap(() => { + Keyboard.dismiss(); + if (rootPost?.id) { + const passProps = {location: Screens.THREAD, post: rootPost, showAddReaction: true}; + const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : ''; + + if (isTablet) { + showModal(Screens.POST_OPTIONS, title, passProps, bottomSheetModalOptions(theme, 'close-post-options')); + } else { + showModalOverCurrentContext(Screens.POST_OPTIONS, passProps); + } + } + }), [rootPost]); + + const containerStyle = useMemo(() => { + const style = [styles.container]; + if (repliesCount === 0) { + style.push({ + borderBottomWidth: 0, + }); + } + return style; + }, [repliesCount]); + + return ( + + + { + repliesCount > 0 ? ( + + ) : ( + + ) + } + + + + + + + + + + + ); +}; + +export default ThreadOverview; diff --git a/app/constants/action_type.ts b/app/constants/action_type.ts index 7193a62a4d..6a2d5534bf 100644 --- a/app/constants/action_type.ts +++ b/app/constants/action_type.ts @@ -5,6 +5,7 @@ import keyMirror from '@utils/key_mirror'; export const POSTS = keyMirror({ RECEIVED_IN_CHANNEL: null, + RECEIVED_IN_THREAD: null, RECEIVED_SINCE: null, RECEIVED_AFTER: null, RECEIVED_BEFORE: null, diff --git a/app/constants/post_draft.ts b/app/constants/post_draft.ts index 2cb6e19063..1f4d0907b9 100644 --- a/app/constants/post_draft.ts +++ b/app/constants/post_draft.ts @@ -7,6 +7,7 @@ export const ICON_SIZE = 24; export const UPDATE_NATIVE_SCROLLVIEW = 'onUpdateNativeScrollView'; export const TYPING_HEIGHT = 26; export const ACCESSORIES_CONTAINER_NATIVE_ID = 'channelAccessoriesContainer'; +export const THREAD_ACCESSORIES_CONTAINER_NATIVE_ID = 'threadAccessoriesContainer'; export const NOTIFY_ALL_MEMBERS = 5; diff --git a/app/database/operator/server_data_operator/handlers/post.ts b/app/database/operator/server_data_operator/handlers/post.ts index 3c83d4b83f..8826df0010 100644 --- a/app/database/operator/server_data_operator/handlers/post.ts +++ b/app/database/operator/server_data_operator/handlers/post.ts @@ -204,12 +204,14 @@ const PostHandler = (superclass: any) => class extends superclass { batch.push(...postEmojis); } - // link the newly received posts - const linkedPosts = createPostsChain({order, posts, previousPostId}); - if (linkedPosts.length) { - const postsInChannel = await this.handlePostsInChannel(linkedPosts, actionType as never, true); - if (postsInChannel.length) { - batch.push(...postsInChannel); + if (actionType !== ActionType.POSTS.RECEIVED_IN_THREAD) { + // link the newly received posts + const linkedPosts = createPostsChain({order, posts, previousPostId}); + if (linkedPosts.length) { + const postsInChannel = await this.handlePostsInChannel(linkedPosts, actionType as never, true); + if (postsInChannel.length) { + batch.push(...postsInChannel); + } } } @@ -275,6 +277,7 @@ const PostHandler = (superclass: any) => class extends superclass { } switch (actionType) { case ActionType.POSTS.RECEIVED_IN_CHANNEL: + case ActionType.POSTS.RECEIVED_IN_THREAD: case ActionType.POSTS.RECEIVED_SINCE: case ActionType.POSTS.RECEIVED_AFTER: case ActionType.POSTS.RECEIVED_BEFORE: diff --git a/app/screens/bottom_sheet/index.tsx b/app/screens/bottom_sheet/index.tsx index 68cb28f9ff..b940f46eb3 100644 --- a/app/screens/bottom_sheet/index.tsx +++ b/app/screens/bottom_sheet/index.tsx @@ -1,14 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {ReactNode, useEffect, useRef} from 'react'; +import React, {ReactNode, useCallback, useEffect, useRef} from 'react'; import {BackHandler, DeviceEventEmitter, Keyboard, StyleSheet, useWindowDimensions, View} from 'react-native'; import {State, TapGestureHandler} from 'react-native-gesture-handler'; import {Navigation as RNN} from 'react-native-navigation'; import Animated from 'react-native-reanimated'; import RNBottomSheet from 'reanimated-bottom-sheet'; -import {Events, Screens} from '@constants'; +import {Events} from '@constants'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {dismissModal} from '@screens/navigation'; @@ -19,42 +19,47 @@ import Indicator from './indicator'; type SlideUpPanelProps = { closeButtonId?: string; + componentId: string; initialSnapIndex?: number; renderContent: () => ReactNode; snapPoints?: Array; } -const BottomSheet = ({closeButtonId, initialSnapIndex = 0, renderContent, snapPoints = ['90%', '50%', 50]}: SlideUpPanelProps) => { +const BottomSheet = ({closeButtonId, componentId, initialSnapIndex = 0, renderContent, snapPoints = ['90%', '50%', 50]}: SlideUpPanelProps) => { const sheetRef = useRef(null); const dimensions = useWindowDimensions(); const isTablet = useIsTablet(); const theme = useTheme(); const lastSnap = snapPoints.length - 1; + const close = useCallback(() => { + dismissModal({componentId}); + }, [componentId]); + useEffect(() => { const listener = DeviceEventEmitter.addListener(Events.CLOSE_BOTTOM_SHEET, () => { if (sheetRef.current) { sheetRef.current.snapTo(lastSnap); } else { - dismissModal({componentId: Screens.BOTTOM_SHEET}); + close(); } }); return () => listener.remove(); - }, []); + }, [close]); useEffect(() => { const listener = BackHandler.addEventListener('hardwareBackPress', () => { if (sheetRef.current) { sheetRef.current.snapTo(1); } else { - dismissModal({componentId: Screens.BOTTOM_SHEET}); + close(); } return true; }); return () => listener.remove(); - }, []); + }, [close]); useEffect(() => { hapticFeedback(); @@ -65,12 +70,12 @@ const BottomSheet = ({closeButtonId, initialSnapIndex = 0, renderContent, snapPo useEffect(() => { const navigationEvents = RNN.events().registerNavigationButtonPressedListener(({buttonId}) => { if (closeButtonId && buttonId === closeButtonId) { - dismissModal({componentId: Screens.BOTTOM_SHEET}); + close(); } }); return () => navigationEvents.remove(); - }, []); + }, [close]); const renderBackdrop = () => { return ( @@ -124,7 +129,7 @@ const BottomSheet = ({closeButtonId, initialSnapIndex = 0, renderContent, snapPo borderRadius={10} initialSnap={initialSnapIndex} renderContent={renderContainerContent} - onCloseEnd={dismissModal} + onCloseEnd={close} enabledBottomInitialAnimation={true} renderHeader={Indicator} enabledContentTapInteraction={false} diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index ffb2c3f234..b5cec3114e 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -3,7 +3,7 @@ import React, {useCallback, useMemo} from 'react'; import {useIntl} from 'react-intl'; -import {DeviceEventEmitter, Keyboard, Platform, View} from 'react-native'; +import {DeviceEventEmitter, Keyboard, Platform, StyleSheet, View} from 'react-native'; import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import CompassIcon from '@components/compass_icon'; @@ -16,7 +16,7 @@ import {useTheme} from '@context/theme'; import {useAppState, useIsTablet} from '@hooks/device'; import {useDefaultHeaderHeight} from '@hooks/header'; import {popTopScreen} from '@screens/navigation'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {changeOpacity} from '@utils/theme'; import ChannelPostList from './channel_post_list'; import OtherMentionsBadge from './other_mentions_badge'; @@ -35,20 +35,11 @@ type ChannelProps = { const edges: Edge[] = ['left', 'right']; -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ +const styles = StyleSheet.create({ flex: { flex: 1, }, - sectionContainer: { - marginTop: 10, - paddingHorizontal: 24, - }, - sectionTitle: { - fontSize: 16, - fontFamily: 'OpenSans-SemiBold', - color: theme.centerChannelColor, - }, -})); +}); const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, memberCount, name, teamId}: ChannelProps) => { const {formatMessage} = useIntl(); @@ -56,7 +47,6 @@ const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, membe const isTablet = useIsTablet(); const insets = useSafeAreaInsets(); const theme = useTheme(); - const styles = getStyleSheet(theme); const defaultHeight = useDefaultHeaderHeight(); const rightButtons: HeaderRightButton[] = useMemo(() => ([{ iconName: 'magnify', diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 90be8b26a7..9f80ed1169 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -110,6 +110,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.SSO: screen = withIntl(require('@screens/sso').default); break; + case Screens.THREAD: + screen = withServerDatabase(require('@screens/thread').default); + break; } if (screen) { diff --git a/app/screens/post_options/options/reply_option.tsx b/app/screens/post_options/options/reply_option.tsx index 3d07218912..f0dfeac333 100644 --- a/app/screens/post_options/options/reply_option.tsx +++ b/app/screens/post_options/options/reply_option.tsx @@ -3,9 +3,11 @@ import React, {useCallback} from 'react'; +import {fetchAndSwitchToThread} from '@actions/remote/thread'; import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; import {t} from '@i18n'; -import {dismissBottomSheet, goToScreen} from '@screens/navigation'; +import {dismissBottomSheet} from '@screens/navigation'; import BaseOption from './base_option'; @@ -15,12 +17,13 @@ type Props = { post: PostModel; } const ReplyOption = ({post}: Props) => { - const handleReply = useCallback(() => { - //todo: @anurag Change below screen name to Screens.THREAD once implemented - // https://mattermost.atlassian.net/browse/MM-39708 - goToScreen('THREADS_SCREEN_NOT_IMPLEMENTED_YET', '', {post}); - dismissBottomSheet(Screens.POST_OPTIONS); - }, [post]); + const serverUrl = useServerUrl(); + + const handleReply = useCallback(async () => { + const rootId = post.rootId || post.id; + await dismissBottomSheet(Screens.POST_OPTIONS); + fetchAndSwitchToThread(serverUrl, rootId); + }, [post, serverUrl]); return ( ); }; diff --git a/app/screens/thread/index.tsx b/app/screens/thread/index.tsx new file mode 100644 index 0000000000..5952f1ddd9 --- /dev/null +++ b/app/screens/thread/index.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Database} from '@constants'; + +import Thread from './thread'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; + +const {MM_TABLES} = Database; +const {SERVER: {POST}} = MM_TABLES; + +const enhanced = withObservables(['rootId'], ({database, rootId}: WithDatabaseArgs & {rootId: string}) => { + return { + rootPost: database.get(POST).query( + Q.where('id', rootId), + ).observe().pipe( + switchMap((posts) => posts[0]?.observe() || of$(undefined)), + ), + }; +}); + +export default withDatabase(enhanced(Thread)); diff --git a/app/screens/thread/thread.tsx b/app/screens/thread/thread.tsx new file mode 100644 index 0000000000..6f8d31f37c --- /dev/null +++ b/app/screens/thread/thread.tsx @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect} from 'react'; +import {BackHandler, StyleSheet, View} from 'react-native'; +import {Navigation} from 'react-native-navigation'; +import {Edge, SafeAreaView} from 'react-native-safe-area-context'; + +import PostDraft from '@components/post_draft'; +import {THREAD_ACCESSORIES_CONTAINER_NATIVE_ID} from '@constants/post_draft'; +import {useAppState} from '@hooks/device'; +import {dismissModal} from '@screens/navigation'; + +import ThreadPostList from './thread_post_list'; + +import type PostModel from '@typings/database/models/servers/post'; + +type ThreadProps = { + closeButtonId: string; + componentId: string; + rootPost?: PostModel; +}; + +const edges: Edge[] = ['left', 'right']; + +const getStyleSheet = StyleSheet.create(() => ({ + flex: { + flex: 1, + }, +})); + +const Thread = ({closeButtonId, componentId, rootPost}: ThreadProps) => { + const appState = useAppState(); + const styles = getStyleSheet(); + + const close = useCallback(() => { + dismissModal({componentId}); + return true; + }, []); + + useEffect(() => { + const unsubscribe = Navigation.events().registerComponentListener({ + navigationButtonPressed: ({buttonId}: { buttonId: string }) => { + switch (buttonId) { + case closeButtonId: + close(); + break; + } + }, + }, componentId); + + return () => { + unsubscribe.remove(); + }; + }, []); + + useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', close); + return () => { + backHandler.remove(); + }; + }, []); + + return ( + <> + + {Boolean(rootPost?.id) && + <> + + + + + + } + + + ); +}; + +export default Thread; diff --git a/app/screens/thread/thread_post_list/index.ts b/app/screens/thread/thread_post_list/index.ts new file mode 100644 index 0000000000..54adb9853f --- /dev/null +++ b/app/screens/thread/thread_post_list/index.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {AppStateStatus} from 'react-native'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {getTimezone} from '@utils/user'; + +import ThreadPostList from './thread_post_list'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type PostModel from '@typings/database/models/servers/post'; +import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +const {SERVER: {MY_CHANNEL, POST, POSTS_IN_THREAD, SYSTEM, USER}} = MM_TABLES; + +type Props = WithDatabaseArgs & { + channelId: string; + forceQueryAfterAppState: AppStateStatus; + rootPost: PostModel; +}; + +const enhanced = withObservables(['channelId', 'forceQueryAfterAppState', 'rootPost'], ({channelId, database, rootPost}: Props) => { + const currentUser = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap((currentUserId) => database.get(USER).findAndObserve(currentUserId.value)), + ); + + return { + currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))), + currentUsername: currentUser.pipe((switchMap((user) => of$(user.username)))), + isTimezoneEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')), + ), + lastViewedAt: database.get(MY_CHANNEL).findAndObserve(channelId).pipe( + switchMap((myChannel) => of$(myChannel.viewedAt)), + ), + posts: database.get(POSTS_IN_THREAD).query( + Q.where('root_id', rootPost.id), + Q.sortBy('latest', Q.desc), + ).observeWithColumns(['earliest', 'latest']).pipe( + switchMap((postsInThread) => { + if (!postsInThread.length) { + return of$([]); + } + + const {earliest, latest} = postsInThread[0]; + return database.get(POST).query( + Q.where('root_id', rootPost.id), + Q.where('create_at', Q.between(earliest, latest)), + Q.sortBy('create_at', Q.desc), + ).observe(); + }), + ), + }; +}); + +export default withDatabase(enhanced(ThreadPostList)); diff --git a/app/screens/thread/thread_post_list/thread_post_list.tsx b/app/screens/thread/thread_post_list/thread_post_list.tsx new file mode 100644 index 0000000000..c5e54e0d9a --- /dev/null +++ b/app/screens/thread/thread_post_list/thread_post_list.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleProp, StyleSheet, ViewStyle} from 'react-native'; +import {Edge, SafeAreaView} from 'react-native-safe-area-context'; + +import PostList from '@components/post_list'; +import {Screens} from '@constants'; +import {useIsTablet} from '@hooks/device'; + +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + channelId: string; + contentContainerStyle?: StyleProp; + currentTimezone: string | null; + currentUsername: string; + isTimezoneEnabled: boolean; + lastViewedAt: number; + nativeID: string; + posts: PostModel[]; + rootPost: PostModel; +} + +const edges: Edge[] = ['bottom']; + +const styles = StyleSheet.create({ + flex: {flex: 1}, +}); + +const ThreadPostList = ({ + channelId, contentContainerStyle, currentTimezone, currentUsername, + isTimezoneEnabled, lastViewedAt, nativeID, posts, rootPost, +}: Props) => { + const isTablet = useIsTablet(); + + const threadPosts = useMemo(() => { + return [...posts, rootPost]; + }, [posts, rootPost]); + + const postList = ( + + ); + + if (isTablet) { + return postList; + } + + return ( + + {postList} + + ); +}; + +export default ThreadPostList; diff --git a/app/utils/post_list/index.ts b/app/utils/post_list/index.ts index e8574c5d86..966989aa9e 100644 --- a/app/utils/post_list/index.ts +++ b/app/utils/post_list/index.ts @@ -44,6 +44,7 @@ const postTypePriority = { export const COMBINED_USER_ACTIVITY = 'user-activity-'; export const DATE_LINE = 'date-'; export const START_OF_NEW_MESSAGES = 'start-of-new-messages'; +export const THREAD_OVERVIEW = 'thread-overview'; export const MAX_COMBINED_SYSTEM_POSTS = 100; function combineUserActivityPosts(orderedPosts: Array) { @@ -225,6 +226,10 @@ export function selectOrderedPosts( } out.push(post); + + if (isThreadScreen && i === posts.length - 1) { + out.push(THREAD_OVERVIEW); + } } // Flip it back to newest to oldest @@ -349,6 +354,10 @@ export function isStartOfNewMessages(item: string) { return item === START_OF_NEW_MESSAGES; } +export function isThreadOverview(item: string) { + return item === THREAD_OVERVIEW; +} + export function preparePostList( posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUsername: string, showJoinLeave: boolean, timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index aa78cba6ba..814dbf5f65 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -75,8 +75,8 @@ "combined_system_message.removed_from_team.one_you": "You were **removed from the team**.", "combined_system_message.removed_from_team.two": "{firstUser} and {secondUser} were **removed from the team**.", "combined_system_message.you": "You", - "create_comment.addComment": "Add a comment...", "create_post.deactivated": "You are viewing an archived channel with a deactivated user.", + "create_post.thread_reply": "Reply to this thread...", "create_post.write": "Write to {channelDisplayName}", "custom_status.expiry_dropdown.custom": "Custom", "custom_status.expiry_dropdown.date_and_time": "Date and Time", @@ -431,6 +431,11 @@ "status_dropdown.set_ooo": "Out Of Office", "team_list.no_other_teams.description": "To join another team, ask a Team Admin for an invitation, or create your own team.", "team_list.no_other_teams.title": "No additional teams to join", + "thread.header.thread": "Thread", + "thread.header.thread_in": "in {channelName}", + "thread.header.thread_dm": "Direct Message Thread", + "thread.noReplies": "No replies yet", + "thread.repliesCount": "{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}", "threads.followMessage": "Follow Message", "threads.followThread": "Follow Thread", "threads.unfollowMessage": "Unfollow Message",