[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 <mattermod@users.noreply.github.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Anurag Shivarathri
2022-03-10 19:15:30 +05:30
committed by GitHub
parent 0c0f92a237
commit 5b44676985
26 changed files with 926 additions and 56 deletions

View File

@@ -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};
}
};

View File

@@ -410,6 +410,23 @@ export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOn
}
};
export const fetchPostThread = async (serverUrl: string, postId: string, fetchOnly = false): Promise<PostsRequest> => {
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) {

View File

@@ -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);
};

View File

@@ -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}'};
}

View File

@@ -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'),
},
};

View File

@@ -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 (
<ThreadOverview
rootId={rootId!}
testID={`${testID}.thread_overview`}
/>
);
}
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 (

View File

@@ -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 (
<View

View File

@@ -8,13 +8,14 @@ import {TouchableHighlight} from 'react-native-gesture-handler';
import {showPermalink} from '@actions/local/permalink';
import {removePost} from '@actions/local/post';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import SystemAvatar from '@components/system_avatar';
import SystemHeader from '@components/system_header';
import * as Screens from '@constants/screens';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {bottomSheetModalOptions, goToScreen, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {fromAutoResponder, isFromWebhook, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -137,8 +138,8 @@ const Post = ({
const isValidSystemMessage = isAutoResponder || !isSystemPost;
if (post.deleteAt === 0 && isValidSystemMessage && !isPendingOrFailed) {
if ([Screens.CHANNEL, Screens.PERMALINK].includes(location)) {
// https://mattermost.atlassian.net/browse/MM-39708
goToScreen('THREADS_SCREEN_NOT_IMPLEMENTED_YET', '', {post});
const rootId = post.rootId || post.id;
fetchAndSwitchToThread(serverUrl, rootId);
}
} else if ((isEphemeral || post.deleteAt > 0)) {
removePost(serverUrl, post);

View File

@@ -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`] = `
<View
style={
Array [
Object {
"borderBottomWidth": 1,
"borderColor": "rgba(63,67,80,0.1)",
"borderTopWidth": 1,
"flexDirection": "row",
"marginVertical": 12,
"paddingHorizontal": 20,
"paddingVertical": 10,
},
Object {
"borderBottomWidth": 0,
},
]
}
testID="thread-overview"
>
<View
style={
Object {
"flex": 1,
}
}
>
<Text
style={
Object {
"color": "rgba(63,67,80,0.64)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
"marginHorizontal": 4,
}
}
>
No replies yet
</Text>
</View>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
testID="thread-overview.save"
>
<View
accessible={true}
collapsable={false}
style={
Object {
"marginLeft": 16,
"opacity": 1,
}
}
>
<Icon
color="#386fe5"
name="bookmark"
size={24}
/>
</View>
</RNGestureHandlerButton>
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
testID="thread-overview.options"
>
<View
accessible={true}
collapsable={false}
style={
Object {
"marginLeft": 16,
"opacity": 1,
}
}
>
<Icon
color="rgba(63,67,80,0.64)"
name="dots-horizontal"
size={24}
/>
</View>
</RNGestureHandlerButton>
</View>
</View>
`;
exports[`ThreadOverview should match snapshot when post is saved and has replies 1`] = `
<View
style={
Array [
Object {
"borderBottomWidth": 1,
"borderColor": "rgba(63,67,80,0.1)",
"borderTopWidth": 1,
"flexDirection": "row",
"marginVertical": 12,
"paddingHorizontal": 20,
"paddingVertical": 10,
},
]
}
testID="thread-overview"
>
<View
style={
Object {
"flex": 1,
}
}
>
<Text
style={
Object {
"color": "rgba(63,67,80,0.64)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
"marginHorizontal": 4,
}
}
>
2 replies
</Text>
</View>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
testID="thread-overview.save"
>
<View
accessible={true}
collapsable={false}
style={
Object {
"marginLeft": 16,
"opacity": 1,
}
}
>
<Icon
color="rgba(63,67,80,0.64)"
name="bookmark-outline"
size={24}
/>
</View>
</RNGestureHandlerButton>
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
testID="thread-overview.options"
>
<View
accessible={true}
collapsable={false}
style={
Object {
"marginLeft": 16,
"opacity": 1,
}
}
>
<Icon
color="rgba(63,67,80,0.64)"
name="dots-horizontal"
size={24}
/>
</View>
</RNGestureHandlerButton>
</View>
</View>
`;

View File

@@ -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<PostModel>(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<PreferenceModel>(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));

View File

@@ -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(<ThreadOverview {...props}/>);
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(<ThreadOverview {...props}/>);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -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 (
<View
style={containerStyle}
testID={testID}
>
<View style={styles.repliesCountContainer}>
{
repliesCount > 0 ? (
<FormattedText
style={styles.repliesCount}
id='thread.repliesCount'
defaultMessage='{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}'
values={{repliesCount}}
/>
) : (
<FormattedText
style={styles.repliesCount}
id='thread.noReplies'
defaultMessage='No replies yet'
/>
)
}
</View>
<View style={styles.optionsContainer}>
<TouchableOpacity
onPress={onHandleSavePress}
style={styles.optionContainer}
testID={`${testID}.save`}
>
<CompassIcon
size={24}
name={isSaved ? 'bookmark' : 'bookmark-outline'}
color={isSaved ? theme.linkColor : changeOpacity(theme.centerChannelColor, 0.64)}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={showPostOptions}
style={styles.optionContainer}
testID={`${testID}.options`}
>
<CompassIcon
size={24}
name={Platform.select({android: 'dots-vertical', default: 'dots-horizontal'})}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
</TouchableOpacity>
</View>
</View>
);
};
export default ThreadOverview;

View File

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

View File

@@ -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;

View File

@@ -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:

View File

@@ -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<string | number>;
}
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<RNBottomSheet>(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}

View File

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

View File

@@ -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) {

View File

@@ -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 (
<BaseOption

View File

@@ -103,12 +103,16 @@ const PostOptions = ({
);
};
// This fixes opening "post options modal" on top of "thread modal"
const additionalSnapPoints = location === Screens.THREAD ? 3 : 2;
return (
<BottomSheet
renderContent={renderContent}
closeButtonId='close-post-options'
componentId={Screens.POST_OPTIONS}
initialSnapIndex={0}
snapPoints={[((snapPoints + 2) * ITEM_HEIGHT), 10]}
snapPoints={[((snapPoints + additionalSnapPoints) * ITEM_HEIGHT), 10]}
/>
);
};

View File

@@ -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<PostModel>(POST).query(
Q.where('id', rootId),
).observe().pipe(
switchMap((posts) => posts[0]?.observe() || of$(undefined)),
),
};
});
export default withDatabase(enhanced(Thread));

View File

@@ -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 (
<>
<SafeAreaView
style={styles.flex}
mode='margin'
edges={edges}
>
{Boolean(rootPost?.id) &&
<>
<View style={styles.flex}>
<ThreadPostList
channelId={rootPost!.channelId}
forceQueryAfterAppState={appState}
nativeID={rootPost!.id}
rootPost={rootPost!}
/>
</View>
<PostDraft
channelId={rootPost!.channelId}
scrollViewNativeID={rootPost!.id}
accessoriesContainerID={THREAD_ACCESSORIES_CONTAINER_NATIVE_ID}
rootId={rootPost!.id}
/>
</>
}
</SafeAreaView>
</>
);
};
export default Thread;

View File

@@ -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<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId) => database.get<UserModel>(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<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')),
),
lastViewedAt: database.get<MyChannelModel>(MY_CHANNEL).findAndObserve(channelId).pipe(
switchMap((myChannel) => of$(myChannel.viewedAt)),
),
posts: database.get<PostsInThreadModel>(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<PostModel>(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));

View File

@@ -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<ViewStyle>;
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 = (
<PostList
channelId={channelId}
contentContainerStyle={contentContainerStyle}
currentTimezone={currentTimezone}
currentUsername={currentUsername}
isTimezoneEnabled={isTimezoneEnabled}
lastViewedAt={lastViewedAt}
location={Screens.THREAD}
nativeID={nativeID}
posts={threadPosts}
rootId={rootPost.id}
shouldShowJoinLeaveMessages={false}
showMoreMessages={false}
showNewMessageLine={false}
testID='thread.post_list'
/>
);
if (isTablet) {
return postList;
}
return (
<SafeAreaView
edges={edges}
style={styles.flex}
>
{postList}
</SafeAreaView>
);
};
export default ThreadPostList;

View File

@@ -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<PostModel | string>) {
@@ -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) {

View File

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