diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index 78a61abcc..9025c1cf2 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -8,7 +8,7 @@ import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; import {getChannelById} from '@queries/servers/channel'; import {getPostById} from '@queries/servers/post'; -import {getCommonSystemValues} from '@queries/servers/system'; +import {getCommonSystemValues, getCurrentTeamId} from '@queries/servers/system'; import {getIsCRTEnabled, getNewestThreadInTeam, getThreadById} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; @@ -180,6 +180,12 @@ export const updateThreadRead = async (serverUrl: string, teamId: string, thread }; export const updateThreadFollowing = async (serverUrl: string, teamId: string, threadId: string, state: boolean) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + + if (!database) { + return {error: `${serverUrl} database not found`}; + } + let client; try { client = NetworkManager.getClient(serverUrl); @@ -187,8 +193,14 @@ export const updateThreadFollowing = async (serverUrl: string, teamId: string, t return {error}; } + // DM/GM doesn't have a teamId, so we pass the current team id + let threadTeamId = teamId; + if (!threadTeamId) { + threadTeamId = await getCurrentTeamId(database); + } + try { - const data = await client.updateThreadFollow('me', teamId, threadId, state); + const data = await client.updateThreadFollow('me', threadTeamId, threadId, state); // Update locally await updateThread(serverUrl, threadId, {is_following: state}); diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index ab9bb0389..fcc0d7c14 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -278,7 +278,8 @@ const ClientChannels = (superclass: any) => class extends superclass { }; viewMyChannel = async (channelId: string, prevChannelId?: string) => { - const data = {channel_id: channelId, prev_channel_id: prevChannelId}; + // collapsed_threads_supported is not based on user preferences but to know if "CLIENT" supports CRT + const data = {channel_id: channelId, prev_channel_id: prevChannelId, collapsed_threads_supported: true}; return this.doFetch( `${this.getChannelsRoute()}/members/me/view`, {method: 'post', body: data}, diff --git a/app/screens/post_options/options/base_option.tsx b/app/components/common_post_options/base_option/index.tsx similarity index 100% rename from app/screens/post_options/options/base_option.tsx rename to app/components/common_post_options/base_option/index.tsx diff --git a/app/screens/post_options/options/copy_permalink_option/copy_permalink_option.tsx b/app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx similarity index 95% rename from app/screens/post_options/options/copy_permalink_option/copy_permalink_option.tsx rename to app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx index 8528709df..0bce40d69 100644 --- a/app/screens/post_options/options/copy_permalink_option/copy_permalink_option.tsx +++ b/app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx @@ -4,13 +4,12 @@ import Clipboard from '@react-native-community/clipboard'; import React, {useCallback} from 'react'; +import {BaseOption} from '@components/common_post_options'; import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {t} from '@i18n'; import {dismissBottomSheet} from '@screens/navigation'; -import BaseOption from '../base_option'; - import type PostModel from '@typings/database/models/servers/post'; type Props = { diff --git a/app/screens/post_options/options/copy_permalink_option/index.tsx b/app/components/common_post_options/copy_permalink_option/index.tsx similarity index 100% rename from app/screens/post_options/options/copy_permalink_option/index.tsx rename to app/components/common_post_options/copy_permalink_option/index.tsx diff --git a/app/screens/post_options/options/follow_option.tsx b/app/components/common_post_options/follow_thread_option/follow_thread_option.tsx similarity index 61% rename from app/screens/post_options/options/follow_option.tsx rename to app/components/common_post_options/follow_thread_option/follow_thread_option.tsx index 024f20226..b6b32a946 100644 --- a/app/screens/post_options/options/follow_option.tsx +++ b/app/components/common_post_options/follow_thread_option/follow_thread_option.tsx @@ -3,26 +3,28 @@ import React from 'react'; +import {updateThreadFollowing} from '@actions/remote/thread'; +import {BaseOption} from '@components/common_post_options'; import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; import {t} from '@i18n'; +import {dismissBottomSheet} from '@screens/navigation'; -import BaseOption from './base_option'; +import type ThreadModel from '@typings/database/models/servers/thread'; type FollowThreadOptionProps = { - thread?: any; - location?: typeof Screens[keyof typeof Screens]; + thread: ThreadModel; + teamId?: string; }; -//todo: to implement CRT follow thread - -const FollowThreadOption = ({thread}: FollowThreadOptionProps) => { +const FollowThreadOption = ({thread, teamId}: FollowThreadOptionProps) => { let id: string; let defaultMessage: string; let icon: string; - if (thread.is_following) { + if (thread.isFollowing) { icon = 'message-minus-outline'; - if (thread?.participants?.length) { + if (thread.replyCount) { id = t('threads.unfollowThread'); defaultMessage = 'Unfollow Thread'; } else { @@ -31,7 +33,7 @@ const FollowThreadOption = ({thread}: FollowThreadOptionProps) => { } } else { icon = 'message-plus-outline'; - if (thread?.participants?.length) { + if (thread.replyCount) { id = t('threads.followThread'); defaultMessage = 'Follow Thread'; } else { @@ -40,8 +42,14 @@ const FollowThreadOption = ({thread}: FollowThreadOptionProps) => { } } + const serverUrl = useServerUrl(); + const handleToggleFollow = () => { - //todo: + if (teamId == null) { + return; + } + updateThreadFollowing(serverUrl, teamId, thread.id, !thread.isFollowing); + dismissBottomSheet(Screens.POST_OPTIONS); }; return ( diff --git a/app/components/common_post_options/follow_thread_option/index.ts b/app/components/common_post_options/follow_thread_option/index.ts new file mode 100644 index 000000000..0b27c98cb --- /dev/null +++ b/app/components/common_post_options/follow_thread_option/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; + +import {observeTeamIdByThread} from '@queries/servers/thread'; + +import FollowThreadOption from './follow_thread_option'; + +import type ThreadModel from '@typings/database/models/servers/thread'; + +const enhanced = withObservables(['thread'], ({thread}: { thread: ThreadModel }) => { + return { + teamId: observeTeamIdByThread(thread), + }; +}); + +export default enhanced(FollowThreadOption); diff --git a/app/components/common_post_options/index.ts b/app/components/common_post_options/index.ts new file mode 100644 index 000000000..743001197 --- /dev/null +++ b/app/components/common_post_options/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default as BaseOption} from './base_option'; +export {default as CopyPermalinkOption} from './copy_permalink_option'; +export {default as FollowThreadOption} from './follow_thread_option'; +export {default as ReplyOption} from './reply_option'; +export {default as SaveOption} from './save_option'; diff --git a/app/screens/post_options/options/reply_option.tsx b/app/components/common_post_options/reply_option.tsx similarity index 81% rename from app/screens/post_options/options/reply_option.tsx rename to app/components/common_post_options/reply_option.tsx index ce89af3c7..4d941396a 100644 --- a/app/screens/post_options/options/reply_option.tsx +++ b/app/components/common_post_options/reply_option.tsx @@ -4,24 +4,24 @@ import React, {useCallback} from 'react'; import {fetchAndSwitchToThread} from '@actions/remote/thread'; +import {BaseOption} from '@components/common_post_options'; import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {t} from '@i18n'; import {dismissBottomSheet} from '@screens/navigation'; -import BaseOption from './base_option'; - import type PostModel from '@typings/database/models/servers/post'; type Props = { post: PostModel; + location?: typeof Screens[keyof typeof Screens]; } -const ReplyOption = ({post}: Props) => { +const ReplyOption = ({post, location}: Props) => { const serverUrl = useServerUrl(); const handleReply = useCallback(async () => { const rootId = post.rootId || post.id; - await dismissBottomSheet(Screens.POST_OPTIONS); + await dismissBottomSheet(location || Screens.POST_OPTIONS); fetchAndSwitchToThread(serverUrl, rootId); }, [post, serverUrl]); diff --git a/app/screens/post_options/options/save_option.tsx b/app/components/common_post_options/save_option.tsx similarity index 95% rename from app/screens/post_options/options/save_option.tsx rename to app/components/common_post_options/save_option.tsx index 9b917ab97..2083efb5e 100644 --- a/app/screens/post_options/options/save_option.tsx +++ b/app/components/common_post_options/save_option.tsx @@ -4,13 +4,12 @@ import React, {useCallback} from 'react'; import {deleteSavedPost, savePostPreference} from '@actions/remote/preference'; +import {BaseOption} from '@components/common_post_options'; import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {t} from '@i18n'; import {dismissBottomSheet} from '@screens/navigation'; -import BaseOption from './base_option'; - type CopyTextProps = { isSaved: boolean; postId: string; diff --git a/app/components/post_list/index.tsx b/app/components/post_list/index.tsx index a4fc270af..8920caeab 100644 --- a/app/components/post_list/index.tsx +++ b/app/components/post_list/index.tsx @@ -31,6 +31,7 @@ type Props = { currentUsername: string; highlightedId?: PostModel['id']; highlightPinnedOrSaved?: boolean; + isCRTEnabled?: boolean; isTimezoneEnabled: boolean; lastViewedAt: number; location: string; @@ -82,6 +83,7 @@ const PostList = ({ footer, highlightedId, highlightPinnedOrSaved = true, + isCRTEnabled, isTimezoneEnabled, lastViewedAt, location, @@ -300,6 +302,7 @@ const PostList = ({ return ( ); - }, [currentTimezone, highlightPinnedOrSaved, isTimezoneEnabled, orderedPosts, shouldRenderReplyButton, theme]); + }, [currentTimezone, highlightPinnedOrSaved, isCRTEnabled, isTimezoneEnabled, orderedPosts, shouldRenderReplyButton, theme]); const scrollToIndex = useCallback((index: number, animated = true, applyOffset = true) => { listRef.current?.scrollToIndex({ diff --git a/app/components/post_list/post/footer/footer.tsx b/app/components/post_list/post/footer/footer.tsx new file mode 100644 index 000000000..5dc3d62ac --- /dev/null +++ b/app/components/post_list/post/footer/footer.tsx @@ -0,0 +1,175 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {TouchableOpacity, View} from 'react-native'; + +import {updateThreadFollowing} from '@actions/remote/thread'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import UserAvatarsStack from '@components/user_avatars_stack'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type ThreadModel from '@typings/database/models/servers/thread'; +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + participants: UserModel[]; + teamId?: string; + testID: string; + thread: ThreadModel; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + const followingButtonContainerBase = { + justifyContent: 'center', + height: 32, + paddingHorizontal: 12, + }; + + return { + container: { + flexDirection: 'row', + alignItems: 'center', + minHeight: 40, + }, + avatarsContainer: { + marginRight: 12, + paddingVertical: 8, + }, + replyIconContainer: { + top: -1, + marginRight: 5, + }, + replies: { + alignSelf: 'center', + color: changeOpacity(theme.centerChannelColor, 0.64), + marginRight: 12, + ...typography('Heading', 75), + }, + notFollowingButtonContainer: { + ...followingButtonContainerBase, + paddingLeft: 0, + }, + notFollowing: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Heading', 75), + }, + followingButtonContainer: { + ...followingButtonContainerBase, + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + borderRadius: 4, + }, + following: { + color: theme.buttonBg, + ...typography('Heading', 75), + }, + followSeparator: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.16), + height: 16, + marginRight: 12, + width: 1, + }, + }; +}); + +const Footer = ({participants, teamId, testID, thread}: Props) => { + const serverUrl = useServerUrl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const toggleFollow = useCallback(preventDoubleTap(() => { + if (teamId == null) { + return; + } + updateThreadFollowing(serverUrl, teamId, thread.id, !thread.isFollowing); + }), [thread.isFollowing]); + + let repliesComponent; + let followButton; + if (thread.replyCount) { + repliesComponent = ( + <> + + + + + + ); + } + if (thread.isFollowing) { + followButton = ( + + + + ); + } else { + followButton = ( + <> + + + + + + ); + } + + const participantsList = useMemo(() => { + if (participants?.length) { + const orderedParticipantsList = [...participants].reverse(); + return orderedParticipantsList; + } + return []; + }, [participants.length]); + + let userAvatarsStack; + if (participantsList.length) { + userAvatarsStack = ( + + ); + } + + return ( + + {userAvatarsStack} + {repliesComponent} + {followButton} + + ); +}; + +export default Footer; diff --git a/app/components/post_list/post/footer/index.ts b/app/components/post_list/post/footer/index.ts new file mode 100644 index 000000000..80fdf7551 --- /dev/null +++ b/app/components/post_list/post/footer/index.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; + +import {observeTeamIdByThread, queryThreadParticipants} from '@queries/servers/thread'; + +import Footer from './footer'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ThreadModel from '@typings/database/models/servers/thread'; + +const enhanced = withObservables( + ['thread'], + ({database, thread}: WithDatabaseArgs & {thread: ThreadModel}) => { + return { + participants: queryThreadParticipants(database, thread.id).observe(), + teamId: observeTeamIdByThread(thread), + }; + }, +); + +export default withDatabase(enhanced(Footer)); diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index 637eb114d..1e8360b95 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -27,6 +27,7 @@ type HeaderProps = { currentUser: UserModel; enablePostUsernameOverride: boolean; isAutoResponse: boolean; + isCRTEnabled?: boolean; isCustomStatusEnabled: boolean; isEphemeral: boolean; isMilitaryTime: boolean; @@ -71,7 +72,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const Header = (props: HeaderProps) => { const { - author, commentCount = 0, currentUser, enablePostUsernameOverride, isAutoResponse, isCustomStatusEnabled, + author, commentCount = 0, currentUser, enablePostUsernameOverride, isAutoResponse, isCRTEnabled, isCustomStatusEnabled, isEphemeral, isMilitaryTime, isPendingOrFailed, isSystemPost, isTimezoneEnabled, isWebHook, location, post, rootPostAuthor, shouldRenderReplyButton, teammateNameDisplay, } = props; @@ -124,13 +125,13 @@ const Header = (props: HeaderProps) => { style={style.time} testID='post_header.date_time' /> - {showReply && commentCount > 0 && - + {!isCRTEnabled && showReply && commentCount > 0 && + } diff --git a/app/components/post_list/post/header/index.ts b/app/components/post_list/post/header/index.ts index 0178f49a5..181e75b61 100644 --- a/app/components/post_list/post/header/index.ts +++ b/app/components/post_list/post/header/index.ts @@ -53,8 +53,8 @@ const withHeaderProps = withObservables( isCustomStatusEnabled, isMilitaryTime, isTimezoneEnabled, - teammateNameDisplay, rootPostAuthor, + teammateNameDisplay, }; }); diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index ea44f23d4..78c802cb0 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -13,6 +13,7 @@ import {queryPostsBetween} from '@queries/servers/post'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {observeCanManageChannelMembers, observePermissionForPost} from '@queries/servers/role'; import {observeConfigBooleanValue} from '@queries/servers/system'; +import {observeThreadById} from '@queries/servers/thread'; import {observeCurrentUser} from '@queries/servers/user'; import {hasJumboEmojiOnly} from '@utils/emoji/helpers'; import {areConsecutivePosts, isPostEphemeral} from '@utils/post'; @@ -28,6 +29,7 @@ import type UserModel from '@typings/database/models/servers/user'; type PropsInput = WithDatabaseArgs & { appsEnabled: boolean; currentUser: UserModel; + isCRTEnabled?: boolean; nextPost: PostModel | undefined; post: PostModel; previousPost: PostModel | undefined; @@ -92,8 +94,8 @@ const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({ })); const withPost = withObservables( - ['currentUser', 'post', 'previousPost', 'nextPost'], - ({appsEnabled, currentUser, database, post, previousPost, nextPost}: PropsInput) => { + ['currentUser', 'isCRTEnabled', 'post', 'previousPost', 'nextPost'], + ({appsEnabled, currentUser, database, isCRTEnabled, post, previousPost, nextPost}: PropsInput) => { let isJumboEmoji = of$(false); let isLastReply = of$(true); let isPostAddChannelMember = of$(false); @@ -150,6 +152,7 @@ const withPost = withObservables( isLastReply, isPostAddChannelMember, post: post.observe(), + thread: isCRTEnabled ? observeThreadById(database, post.id) : of$(undefined), reactionsCount: post.reactions.observeCount(), }; }); diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 2e982728c..8a89f60c1 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -21,11 +21,14 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import Avatar from './avatar'; import Body from './body'; +import Footer from './footer'; import Header from './header'; import PreHeader from './pre_header'; import SystemMessage from './system_message'; +import UnreadDot from './unread_dot'; import type PostModel from '@typings/database/models/servers/post'; +import type ThreadModel from '@typings/database/models/servers/thread'; import type UserModel from '@typings/database/models/servers/user'; type PostProps = { @@ -39,6 +42,7 @@ type PostProps = { highlightPinnedOrSaved?: boolean; highlightReplyBar: boolean; isConsecutivePost?: boolean; + isCRTEnabled?: boolean; isEphemeral: boolean; isFirstReply?: boolean; isSaved?: boolean; @@ -55,6 +59,7 @@ type PostProps = { skipPinnedHeader?: boolean; style?: StyleProp; testID?: string; + thread?: ThreadModel; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -97,9 +102,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const Post = ({ appsEnabled, canDelete, currentUser, differentThreadSequence, filesCount, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar, - isConsecutivePost, isEphemeral, isFirstReply, isSaved, isJumboEmoji, isLastReply, isPostAddChannelMember, + isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isJumboEmoji, isLastReply, isPostAddChannelMember, location, post, reactionsCount, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style, - testID, previousPost, + testID, thread, previousPost, }: PostProps) => { const pressDetected = useRef(false); const intl = useIntl(); @@ -223,6 +228,7 @@ const Post = ({ currentUser={currentUser} differentThreadSequence={differentThreadSequence} isAutoResponse={isAutoResponder} + isCRTEnabled={isCRTEnabled} isEphemeral={isEphemeral} isPendingOrFailed={isPendingOrFailed} isSystemPost={isSystemPost} @@ -264,6 +270,24 @@ const Post = ({ ); } + let unreadDot; + let footer; + if (isCRTEnabled && thread) { + if (thread.replyCount > 0 || thread.isFollowing) { + footer = ( +