forked from Ivasoft/mattermost-mobile
[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:
committed by
GitHub
parent
0c0f92a237
commit
5b44676985
79
app/actions/local/thread.ts
Normal file
79
app/actions/local/thread.ts
Normal 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};
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
13
app/actions/remote/thread.ts
Normal file
13
app/actions/remote/thread.ts
Normal 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);
|
||||
};
|
||||
@@ -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}'};
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
46
app/components/post_list/thread_overview/index.ts
Normal file
46
app/components/post_list/thread_overview/index.ts
Normal 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));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
148
app/components/post_list/thread_overview/thread_overview.tsx
Normal file
148
app/components/post_list/thread_overview/thread_overview.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
30
app/screens/thread/index.tsx
Normal file
30
app/screens/thread/index.tsx
Normal 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));
|
||||
94
app/screens/thread/thread.tsx
Normal file
94
app/screens/thread/thread.tsx
Normal 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;
|
||||
65
app/screens/thread/thread_post_list/index.ts
Normal file
65
app/screens/thread/thread_post_list/index.ts
Normal 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));
|
||||
75
app/screens/thread/thread_post_list/thread_post_list.tsx
Normal file
75
app/screens/thread/thread_post_list/thread_post_list.tsx
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user