diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 60a2c7eb85..827c82f0d6 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -132,7 +132,7 @@ export const removePost = async (serverUrl: string, post: PostModel | Post) => { return {post}; }; -export const markPostAsDeleted = async (serverUrl: string, post: Post) => { +export const markPostAsDeleted = async (serverUrl: string, post: Post, prepareRecordsOnly = false) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; @@ -140,41 +140,18 @@ export const markPostAsDeleted = async (serverUrl: string, post: Post) => { const dbPost = await getPostById(operator.database, post.id); if (!dbPost) { - return {}; + return {error: 'Post not found'}; } - dbPost.prepareUpdate((p) => { + const model = dbPost.prepareUpdate((p) => { p.deleteAt = Date.now(); p.message = ''; p.metadata = null; p.props = undefined; }); - operator.batchRecords([dbPost]); - - return {post: dbPost}; -}; - -export const processPostsFetched = async (serverUrl: string, actionType: string, data: PostResponse, fetchOnly = false) => { - const order = data.order; - const posts = Object.values(data.posts) as Post[]; - const previousPostId = data.prev_post_id; - - if (!fetchOnly) { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (operator) { - await operator.handlePosts({ - actionType, - order, - posts, - previousPostId, - }); - } + if (!prepareRecordsOnly) { + operator.batchRecords([dbPost]); } - - return { - posts, - order, - previousPostId, - }; + return {model}; }; diff --git a/app/actions/local/thread.ts b/app/actions/local/thread.ts index d4e720518e..40c97b2a2c 100644 --- a/app/actions/local/thread.ts +++ b/app/actions/local/thread.ts @@ -1,16 +1,19 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {General, Screens} from '@constants'; +import {ActionType, General, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {getTranslations, t} from '@i18n'; import {getChannelById} from '@queries/servers/channel'; import {getPostById} from '@queries/servers/post'; +import {getIsCRTEnabled, getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; import {goToScreen} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import {changeOpacity} from '@utils/theme'; +import type Model from '@nozbe/watermelondb/Model'; + export const switchToThread = async (serverUrl: string, rootId: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { @@ -37,6 +40,25 @@ export const switchToThread = async (serverUrl: string, rootId: string) => { return {error: 'Theme not found'}; } + // Modal right buttons + const rightButtons = []; + + const isCRTEnabled = await getIsCRTEnabled(database); + if (isCRTEnabled) { + // CRT: Add follow/following button + rightButtons.push({ + id: 'thread-follow-button', + component: { + id: post.id, + name: Screens.THREAD_FOLLOW_BUTTON, + passProps: { + teamId: channel.teamId, + threadId: post.id, + }, + }, + }); + } + // Get translation by user locale const translations = getTranslations(user.locale); @@ -62,6 +84,7 @@ export const switchToThread = async (serverUrl: string, rootId: string) => { color: changeOpacity(theme.sidebarHeaderTextColor, 0.72), text: subtitle, }, + rightButtons, }, }); return {}; @@ -69,3 +92,155 @@ export const switchToThread = async (serverUrl: string, rootId: string) => { return {error}; } }; + +// When new post arrives: +// 1. If a reply, then update the reply_count, add user as the participant +// 2. Else add the post as a thread +export const createThreadFromNewPost = async (serverUrl: string, post: Post, prepareRecordsOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const models: Model[] = []; + if (post.root_id) { + // Update the thread data: `reply_count` + const {model: threadModel} = await updateThread(serverUrl, post.root_id, {reply_count: post.reply_count}, true); + if (threadModel) { + models.push(threadModel); + } + + // Add user as a participant to the thread + const threadParticipantModels = await operator.handleThreadParticipants({ + threadsParticipants: [{ + thread_id: post.root_id, + participants: [{ + thread_id: post.root_id, + id: post.user_id, + }], + }], + prepareRecordsOnly: true, + skipSync: true, + }); + if (threadParticipantModels?.length) { + models.push(...threadParticipantModels); + } + } else { // If the post is a root post, then we need to add it to the thread table + const threadModels = await prepareThreadsFromReceivedPosts(operator, [post]); + if (threadModels?.length) { + models.push(...threadModels); + } + } + + if (models.length && !prepareRecordsOnly) { + await operator.batchRecords(models); + } + + return {models}; +}; + +// On receiving threads, Along with the "threads" & "thread participants", extract and save "posts" & "users" +export const processReceivedThreads = async (serverUrl: string, threads: Thread[], teamId: string, prepareRecordsOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const models: Model[] = []; + + const posts: Post[] = []; + const users: UserProfile[] = []; + + // Extract posts & users from the received threads + for (let i = 0; i < threads.length; i++) { + const {participants, post} = threads[i]; + posts.push(post); + participants.forEach((participant) => users.push(participant)); + } + + const postModels = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [], + posts, + prepareRecordsOnly: true, + }); + + if (postModels.length) { + models.push(...postModels); + } + + const threadModels = await operator.handleThreads({ + threads, + teamId, + prepareRecordsOnly: true, + }); + + if (threadModels.length) { + models.push(...threadModels); + } + + const userModels = await operator.handleUsers({ + users, + prepareRecordsOnly: true, + }); + + if (userModels.length) { + models.push(...userModels); + } + + if (models.length && !prepareRecordsOnly) { + await operator.batchRecords(models); + } + return {models}; +}; + +export const markTeamThreadsAsRead = async (serverUrl: string, teamId: string, prepareRecordsOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + try { + const {database} = operator; + const threads = await queryThreadsInTeam(database, teamId, true).fetch(); + const models = threads.map((thread) => thread.prepareUpdate((record) => { + record.unreadMentions = 0; + record.unreadReplies = 0; + record.lastViewedAt = Date.now(); + })); + if (!prepareRecordsOnly) { + await operator.batchRecords(models); + } + return {models}; + } catch (error) { + return {error}; + } +}; + +export const updateThread = async (serverUrl: string, threadId: string, updatedThread: Partial, prepareRecordsOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + const {database} = operator; + const thread = await getThreadById(database, threadId); + if (thread) { + const model = thread.prepareUpdate((record) => { + record.isFollowing = updatedThread.is_following ?? record.isFollowing; + record.replyCount = updatedThread.reply_count ?? record.replyCount; + + record.lastViewedAt = updatedThread.last_viewed_at ?? record.lastViewedAt; + record.unreadMentions = updatedThread.unread_mentions ?? record.unreadMentions; + record.unreadReplies = updatedThread.unread_replies ?? record.unreadReplies; + }); + if (!prepareRecordsOnly) { + await operator.batchRecords([model]); + } + return {model}; + } + return {error: 'Thread not found'}; + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index 0e010ce267..c09c018ad4 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -7,8 +7,9 @@ import {DeviceEventEmitter} from 'react-native'; import {markChannelAsUnread, updateLastPostAt} from '@actions/local/channel'; -import {processPostsFetched, removePost} from '@actions/local/post'; +import {removePost} from '@actions/local/post'; import {addRecentReaction} from '@actions/local/reactions'; +import {createThreadFromNewPost} from '@actions/local/thread'; import {ActionType, Events, General, Post, ServerErrors} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; @@ -20,8 +21,10 @@ import {prepareMissingChannelsForAllTeams, queryAllMyChannel} from '@queries/ser import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; import {getPostById, getRecentPostsInChannel} from '@queries/servers/post'; import {getCurrentUserId, getCurrentChannelId} from '@queries/servers/system'; +import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; import {queryAllUsers} from '@queries/servers/user'; import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers'; +import {processPostsFetched} from '@utils/post'; import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list'; import {forceLogoutIfNecessary} from './session'; @@ -62,11 +65,13 @@ export const createPost = async (serverUrl: string, post: Partial, files: return {error}; } - const currentUserId = await getCurrentUserId(operator.database); + const {database} = operator; + + const currentUserId = await getCurrentUserId(database); const timestamp = Date.now(); const pendingPostId = post.pending_post_id || `${currentUserId}:${timestamp}`; - const existing = await getPostById(operator.database, pendingPostId); + const existing = await getPostById(database, pendingPostId); if (existing && !existing.props.failed) { return {data: false}; } @@ -111,22 +116,33 @@ export const createPost = async (serverUrl: string, post: Partial, files: initialPostModels.push(...postModels); } - const customEmojis = await queryAllCustomEmojis(operator.database).fetch(); + const customEmojis = await queryAllCustomEmojis(database).fetch(); const emojisInMessage = matchEmoticons(newPost.message); const reactionModels = await addRecentReaction(serverUrl, getValidEmojis(emojisInMessage, customEmojis), true); if (!('error' in reactionModels) && reactionModels.length) { initialPostModels.push(...reactionModels); } - operator.batchRecords(initialPostModels); + await operator.batchRecords(initialPostModels); + + const isCRTEnabled = await getIsCRTEnabled(database); try { const created = await client.createPost(newPost); - await operator.handlePosts({ + const models: Model[] = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [created.id], posts: [created], + prepareRecordsOnly: true, }); + if (isCRTEnabled) { + const {models: threadModels} = await createThreadFromNewPost(serverUrl, created, true); + if (threadModels?.length) { + models.push(...threadModels); + } + } + await operator.batchRecords(models); + newPost = created; } catch (error: any) { const errorPost = { @@ -147,11 +163,19 @@ export const createPost = async (serverUrl: string, post: Partial, files: ) { await removePost(serverUrl, databasePost); } else { - await operator.handlePosts({ + const models: Model[] = await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [errorPost.id], posts: [errorPost], + prepareRecordsOnly: true, }); + if (isCRTEnabled) { + const {models: threadModels} = await createThreadFromNewPost(serverUrl, errorPost, true); + if (threadModels?.length) { + models.push(...threadModels); + } + } + await operator.batchRecords(models); } } @@ -229,6 +253,14 @@ export const fetchPostsForChannel = async (serverUrl: string, channelId: string, models.push(memberModel); } + const isCRTEnabled = await getIsCRTEnabled(operator.database); + if (isCRTEnabled) { + const threadModels = await prepareThreadsFromReceivedPosts(operator, data.posts); + if (threadModels?.length) { + models.push(...threadModels); + } + } + if (models.length) { await operator.batchRecords(models); } @@ -259,6 +291,10 @@ export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: C }; export const fetchPosts = async (serverUrl: string, channelId: string, page = 0, perPage = General.POST_CHUNK_SIZE, fetchOnly = false): Promise => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } let client: Client; try { client = NetworkManager.getClient(serverUrl); @@ -267,8 +303,26 @@ export const fetchPosts = async (serverUrl: string, channelId: string, page = 0, } try { - const data = await client.getPosts(channelId, page, perPage); - return processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_IN_CHANNEL, data, fetchOnly); + const isCRTEnabled = await getIsCRTEnabled(operator.database); + const data = await client.getPosts(channelId, page, perPage, isCRTEnabled, isCRTEnabled); + const result = await processPostsFetched(data); + if (!fetchOnly) { + const models = await operator.handlePosts({ + ...result, + actionType: ActionType.POSTS.RECEIVED_SINCE, + prepareRecordsOnly: true, + }); + if (isCRTEnabled) { + const threadModels = await prepareThreadsFromReceivedPosts(operator, result.posts); + if (threadModels?.length) { + models.push(...threadModels); + } + } + if (models.length) { + await operator.batchRecords(models); + } + } + return result; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; @@ -294,8 +348,9 @@ export const fetchPostsBefore = async (serverUrl: string, channelId: string, pos if (activeServerUrl === serverUrl) { DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, true); } - const data = await client.getPostsBefore(channelId, postId, 0, perPage); - const result = await processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_BEFORE, data, true); + const isCRTEnabled = await getIsCRTEnabled(operator.database); + const data = await client.getPostsBefore(channelId, postId, 0, perPage, isCRTEnabled, isCRTEnabled); + const result = await processPostsFetched(data); if (activeServerUrl === serverUrl) { DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, false); @@ -317,6 +372,13 @@ export const fetchPostsBefore = async (serverUrl: string, channelId: string, pos models.push(...userModels); } + if (isCRTEnabled) { + const threadModels = await prepareThreadsFromReceivedPosts(operator, result.posts); + if (threadModels?.length) { + models.push(...threadModels); + } + } + await operator.batchRecords(models); } catch (error) { // eslint-disable-next-line no-console @@ -335,6 +397,11 @@ export const fetchPostsBefore = async (serverUrl: string, channelId: string, pos }; export const fetchPostsSince = async (serverUrl: string, channelId: string, since: number, fetchOnly = false): Promise => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + let client: Client; try { client = NetworkManager.getClient(serverUrl); @@ -343,8 +410,26 @@ export const fetchPostsSince = async (serverUrl: string, channelId: string, sinc } try { - const data = await client.getPostsSince(channelId, since); - return processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_SINCE, data, fetchOnly); + const isCRTEnabled = await getIsCRTEnabled(operator.database); + const data = await client.getPostsSince(channelId, since, isCRTEnabled, isCRTEnabled); + const result = await processPostsFetched(data); + if (!fetchOnly) { + const models = await operator.handlePosts({ + ...result, + actionType: ActionType.POSTS.RECEIVED_SINCE, + prepareRecordsOnly: true, + }); + if (isCRTEnabled) { + const threadModels = await prepareThreadsFromReceivedPosts(operator, result.posts); + if (threadModels?.length) { + models.push(...threadModels); + } + } + if (models.length) { + await operator.batchRecords(models); + } + } + return result; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; @@ -423,6 +508,11 @@ export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOn }; export const fetchPostThread = async (serverUrl: string, postId: string, fetchOnly = false): Promise => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + let client: Client; try { client = NetworkManager.getClient(serverUrl); @@ -432,7 +522,25 @@ export const fetchPostThread = async (serverUrl: string, postId: string, fetchOn try { const data = await client.getPostThread(postId); - return processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_IN_THREAD, data, fetchOnly); + const result = processPostsFetched(data); + if (!fetchOnly) { + const models = await operator.handlePosts({ + ...result, + actionType: ActionType.POSTS.RECEIVED_IN_THREAD, + prepareRecordsOnly: true, + }); + const isCRTEnabled = await getIsCRTEnabled(operator.database); + if (isCRTEnabled) { + const threadModels = await prepareThreadsFromReceivedPosts(operator, result.posts); + if (threadModels?.length) { + models.push(...threadModels); + } + } + if (models.length) { + await operator.batchRecords(models); + } + } + return result; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; @@ -468,7 +576,7 @@ export async function fetchPostsAround(serverUrl: string, channelId: string, pos order: [], }; - const data = await processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_AROUND, preData, true); + const data = processPostsFetched(preData); let posts: Model[] = []; const models: Model[] = []; @@ -494,6 +602,14 @@ export async function fetchPostsAround(serverUrl: string, channelId: string, pos }); models.push(...posts); + + const isCRTEnabled = await getIsCRTEnabled(operator.database); + if (isCRTEnabled) { + const threadModels = await prepareThreadsFromReceivedPosts(operator, data.posts); + if (threadModels?.length) { + models.push(...threadModels); + } + } await operator.batchRecords(models); } @@ -579,9 +695,8 @@ export async function fetchMissingChannelsFromPosts(serverUrl: string, posts: Po } return mdls; }); - - if (models) { - operator.batchRecords(models); + if (models.length) { + await operator.batchRecords(models); } } } @@ -630,6 +745,14 @@ export const fetchPostById = async (serverUrl: string, postId: string, fetchOnly models.push(...users); } + const isCRTEnabled = await getIsCRTEnabled(operator.database); + if (isCRTEnabled) { + const threadModels = await prepareThreadsFromReceivedPosts(operator, [post]); + if (threadModels?.length) { + models.push(...threadModels); + } + } + await operator.batchRecords(models); } @@ -833,6 +956,11 @@ export async function fetchSavedPosts(serverUrl: string, teamId?: string, channe }), ); + const isCRTEnabled = await getIsCRTEnabled(operator.database); + if (isCRTEnabled) { + promises.push(prepareThreadsFromReceivedPosts(operator, postsArray)); + } + const modelArrays = await Promise.all(promises); const models = modelArrays.flatMap((mdls) => { if (!mdls || !mdls.length) { diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index 3923ccab2d..1c5cc96b6d 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -1,12 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {processPostsFetched} from '@actions/local/post'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@init/network_manager'; import {prepareMissingChannelsForAllTeams} from '@queries/servers/channel'; import {getCurrentUser} from '@queries/servers/user'; +import {processPostsFetched} from '@utils/post'; import {fetchPostAuthors, fetchMissingChannelsFromPosts} from './post'; import {forceLogoutIfNecessary} from './session'; @@ -119,6 +119,12 @@ export async function fetchRecentMentions(serverUrl: string): Promise => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + let client: Client; try { client = NetworkManager.getClient(serverUrl); @@ -134,5 +140,11 @@ export const searchPosts = async (serverUrl: string, params: PostSearchParams): return {error}; } - return processPostsFetched(serverUrl, '', data, false); + const result = processPostsFetched(data); + await operator.handlePosts({ + ...result, + actionType: '', + }); + + return result; }; diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index 91a241a3b6..be2fae1332 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -1,13 +1,196 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {switchToThread} from '@actions/local/thread'; +import {markTeamThreadsAsRead, processReceivedThreads, switchToThread, updateThread} from '@actions/local/thread'; import {fetchPostThread} from '@actions/remote/post'; +import {General} from '@constants'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@init/network_manager'; +import {getChannelById} from '@queries/servers/channel'; +import {getPostById} from '@queries/servers/post'; +import {getCommonSystemValues} from '@queries/servers/system'; +import {getIsCRTEnabled, getThreadById} from '@queries/servers/thread'; + +import {forceLogoutIfNecessary} from './session'; + +type FetchThreadsRequest = { + error?: unknown; +} | { + data: GetUserThreadsResponse; +}; + +type FetchThreadsOptions = { + before?: string; + after?: string; + perPage?: number; + deleted?: boolean; + unread?: boolean; + since?: number; +} export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + // Load thread before we open to the thread modal - // https://mattermost.atlassian.net/browse/MM-42232 + // @Todo: https://mattermost.atlassian.net/browse/MM-42232 fetchPostThread(serverUrl, rootId); + // Mark thread as read + const isCRTEnabled = await getIsCRTEnabled(database); + if (isCRTEnabled) { + const post = await getPostById(database, rootId); + if (post) { + const thread = await getThreadById(database, rootId); + if (thread?.unreadReplies || thread?.unreadMentions) { + const channel = await getChannelById(database, post.channelId); + if (channel) { + updateThreadRead(serverUrl, channel.teamId, thread.id, Date.now()); + } + } + } + } + switchToThread(serverUrl, rootId); + + return {}; +}; + +export const fetchThreads = async ( + serverUrl: string, + teamId: string, + { + before, + after, + perPage = General.CRT_CHUNK_SIZE, + deleted = false, + unread = false, + since = 0, + }: FetchThreadsOptions = { + perPage: General.CRT_CHUNK_SIZE, + deleted: false, + unread: false, + since: 0, + }, +): Promise => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + let client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const {config} = await getCommonSystemValues(database); + + const data = await client.getThreads('me', teamId, before, after, perPage, deleted, unread, since, config.Version); + + const {threads} = data; + + if (threads.length) { + // Mark all fetched threads as following + threads.forEach((thread: Thread) => { + thread.is_following = true; + }); + + await processReceivedThreads(serverUrl, threads, teamId); + } + + return {data}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const fetchThread = async (serverUrl: string, teamId: string, threadId: string, extended?: boolean) => { + let client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const thread = await client.getThread('me', teamId, threadId, extended); + + await processReceivedThreads(serverUrl, [thread], teamId); + + return {data: thread}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const updateTeamThreadsAsRead = async (serverUrl: string, teamId: string) => { + let client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const data = await client.updateTeamThreadsAsRead('me', teamId); + + // Update locally + await markTeamThreadsAsRead(serverUrl, teamId); + + return {data}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const updateThreadRead = async (serverUrl: string, teamId: string, threadId: string, timestamp: number) => { + let client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const data = await client.updateThreadRead('me', teamId, threadId, timestamp); + + // Update locally + await updateThread(serverUrl, threadId, { + last_viewed_at: timestamp, + }); + + return {data}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const updateThreadFollowing = async (serverUrl: string, teamId: string, threadId: string, state: boolean) => { + let client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const data = await client.updateThreadFollow('me', teamId, threadId, state); + + // Update locally + await updateThread(serverUrl, threadId, {is_following: state}); + + return {data}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } }; diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 7eb89cd64b..8ddf9d4a5d 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -25,6 +25,7 @@ import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReaction import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles'; import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system'; import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent} from './teams'; +import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads'; import {handleUserUpdatedEvent, handleUserTypingEvent} from './users'; // ESR: 5.37 @@ -325,17 +326,17 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { break; case WebsocketEvents.THREAD_UPDATED: + handleThreadUpdatedEvent(serverUrl, msg); break; - // return dispatch(handleThreadUpdated(msg)); case WebsocketEvents.THREAD_READ_CHANGED: + handleThreadReadChangedEvent(serverUrl, msg); break; - // return dispatch(handleThreadReadChanged(msg)); case WebsocketEvents.THREAD_FOLLOW_CHANGED: + handleThreadFollowChangedEvent(serverUrl, msg); break; - // return dispatch(handleThreadFollowChanged(msg)); case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: break; diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts index 4a6b1561e4..00c1a4f800 100644 --- a/app/actions/websocket/posts.ts +++ b/app/actions/websocket/posts.ts @@ -6,13 +6,16 @@ import {DeviceEventEmitter} from 'react-native'; import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel'; import {markPostAsDeleted} from '@actions/local/post'; +import {createThreadFromNewPost, updateThread} from '@actions/local/thread'; import {fetchMyChannel, markChannelAsRead} from '@actions/remote/channel'; import {fetchPostAuthors, fetchPostById} from '@actions/remote/post'; +import {fetchThread} from '@actions/remote/thread'; import {ActionType, Events} from '@constants'; import DatabaseManager from '@database/manager'; -import {getMyChannel} from '@queries/servers/channel'; +import {getChannelById, getMyChannel} from '@queries/servers/channel'; import {getPostById} from '@queries/servers/post'; import {getCurrentChannelId, getCurrentUserId} from '@queries/servers/system'; +import {getIsCRTEnabled} from '@queries/servers/thread'; import {isFromWebhook, isSystemMessage, shouldIgnorePost} from '@utils/post'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -31,15 +34,17 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag return; } + const {database} = operator; + let post: Post; try { post = JSON.parse(msg.data.post); } catch { return; } - const currentUserId = await getCurrentUserId(operator.database); + const currentUserId = await getCurrentUserId(database); - const existing = await getPostById(operator.database, post.pending_post_id); + const existing = await getPostById(database, post.pending_post_id); if (existing) { return; @@ -58,8 +63,16 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag models.push(...postModels); } + const isCRTEnabled = await getIsCRTEnabled(database); + if (isCRTEnabled) { + const {models: threadModels} = await createThreadFromNewPost(serverUrl, post, true); + if (threadModels?.length) { + models.push(...threadModels); + } + } + // Ensure the channel membership - let myChannel = await getMyChannel(operator.database, post.channel_id); + let myChannel = await getMyChannel(database, post.channel_id); if (myChannel) { const {member} = await updateLastPostAt(serverUrl, post.channel_id, post.create_at, false); if (member) { @@ -77,7 +90,7 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag return; } - myChannel = await getMyChannel(operator.database, post.channel_id); + myChannel = await getMyChannel(database, post.channel_id); if (!myChannel) { return; } @@ -85,14 +98,14 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag // If we don't have the root post for this post, fetch it from the server if (post.root_id) { - const rootPost = await getPostById(operator.database, post.root_id); + const rootPost = await getPostById(database, post.root_id); if (!rootPost) { fetchPostById(serverUrl, post.root_id); } } - const currentChannelId = await getCurrentChannelId(operator.database); + const currentChannelId = await getCurrentChannelId(database); if (post.channel_id === currentChannelId) { const data = { @@ -112,11 +125,6 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag } } - // TODO Thread related functionality: https://mattermost.atlassian.net/browse/MM-41084 - //const viewingGlobalThreads = getViewingGlobalThreads(state); - // const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); - // actions.push(receivedNewPost(post, collapsedThreadsEnabled)); - if (!shouldIgnorePost(post)) { let markAsViewed = false; let markAsRead = false; @@ -203,10 +211,44 @@ export async function handlePostEdited(serverUrl: string, msg: WebSocketMessage) } } -export function handlePostDeleted(serverUrl: string, msg: WebSocketMessage) { +export async function handlePostDeleted(serverUrl: string, msg: WebSocketMessage) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return; + } try { - const data: Post = JSON.parse(msg.data.post); - markPostAsDeleted(serverUrl, data); + const {database} = operator; + + const post: Post = JSON.parse(msg.data.post); + + const models: Model[] = []; + + const {model: deleteModel} = await markPostAsDeleted(serverUrl, post, true); + if (deleteModel) { + models.push(deleteModel); + } + + // update thread when a reply is deleted and CRT is enabled + if (post.root_id) { + const isCRTEnabled = await getIsCRTEnabled(database); + if (isCRTEnabled) { + // Update reply_count of the thread; + // Note: reply_count includes current deleted count, So subtract 1 from reply_count + const {model: threadModel} = await updateThread(serverUrl, post.root_id, {reply_count: post.reply_count - 1}, true); + if (threadModel) { + models.push(threadModel); + } + + const channel = await getChannelById(database, post.channel_id); + if (channel) { + fetchThread(serverUrl, channel.teamId, post.root_id); + } + } + } + + if (models.length) { + await operator.batchRecords(models); + } } catch { // Do nothing } diff --git a/app/actions/websocket/threads.ts b/app/actions/websocket/threads.ts new file mode 100644 index 0000000000..1d5bd605c5 --- /dev/null +++ b/app/actions/websocket/threads.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {markTeamThreadsAsRead, processReceivedThreads, updateThread} from '@actions/local/thread'; +import DatabaseManager from '@database/manager'; + +export async function handleThreadUpdatedEvent(serverUrl: string, msg: WebSocketMessage): Promise { + try { + const thread = JSON.parse(msg.data.thread) as Thread; + const teamId = msg.broadcast.team_id; + + // Mark it as following + thread.is_following = true; + processReceivedThreads(serverUrl, [thread], teamId); + } catch (error) { + // Do nothing + } +} + +export async function handleThreadReadChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise { + const operator = DatabaseManager.serverDatabases[serverUrl].operator; + if (!operator) { + return; + } + + try { + if (operator) { + const {thread_id, timestamp, unread_mentions, unread_replies} = msg.data as ThreadReadChangedData; + if (thread_id) { + await updateThread(serverUrl, thread_id, { + last_viewed_at: timestamp, + unread_mentions, + unread_replies, + }); + } else { + await markTeamThreadsAsRead(serverUrl, msg.broadcast.team_id); + } + } + } catch (error) { + // Do nothing + } +} + +export async function handleThreadFollowChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise { + const operator = DatabaseManager.serverDatabases[serverUrl].operator; + if (!operator) { + return; + } + + try { + if (operator) { + const {reply_count, state, thread_id} = msg.data as { + reply_count: number; + state: boolean; + thread_id: string; + }; + await updateThread(serverUrl, thread_id, { + is_following: state, + reply_count, + }); + } + } catch (error) { + // Do nothing + } +} diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index 9aaa42079f..39ff759771 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -193,6 +193,14 @@ export default class ClientBase { return `${this.urlVersion}/redirect_location`; } + getThreadsRoute(userId: string, teamId: string): string { + return `${this.getUserRoute(userId)}/teams/${teamId}/threads`; + } + + getThreadRoute(userId: string, teamId: string, threadId: string): string { + return `${this.getThreadsRoute(userId, teamId)}/${threadId}`; + } + getAppsProxyRoute() { return '/plugins/com.mattermost.apps'; } diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts index 36cb12f507..fc8e2dce68 100644 --- a/app/client/rest/index.ts +++ b/app/client/rest/index.ts @@ -16,6 +16,7 @@ import ClientIntegrations, {ClientIntegrationsMix} from './integrations'; import ClientPosts, {ClientPostsMix} from './posts'; import ClientPreferences, {ClientPreferencesMix} from './preferences'; import ClientTeams, {ClientTeamsMix} from './teams'; +import ClientThreads, {ClientThreadsMix} from './threads'; import ClientTos, {ClientTosMix} from './tos'; import ClientUsers, {ClientUsersMix} from './users'; @@ -33,6 +34,7 @@ interface Client extends ClientBase, ClientPostsMix, ClientPreferencesMix, ClientTeamsMix, + ClientThreadsMix, ClientTosMix, ClientUsersMix {} @@ -49,6 +51,7 @@ class Client extends mix(ClientBase).with( ClientPosts, ClientPreferences, ClientTeams, + ClientThreads, ClientTos, ClientUsers, ) { diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 6e85fd3318..693e1640cb 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -11,11 +11,11 @@ export interface ClientPostsMix { getPost: (postId: string) => Promise; patchPost: (postPatch: Partial & {id: string}) => Promise; deletePost: (postId: string) => Promise; - getPostThread: (postId: string) => Promise; - getPosts: (channelId: string, page?: number, perPage?: number) => Promise; - getPostsSince: (channelId: string, since: number) => Promise; - getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number) => Promise; - getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number) => Promise; + getPostThread: (postId: string, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; + getPosts: (channelId: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; + getPostsSince: (channelId: string, since: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; + getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; + getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; getFileInfosForPost: (postId: string) => Promise; getSavedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise; getPinnedPosts: (channelId: string) => Promise; @@ -79,41 +79,41 @@ const ClientPosts = (superclass: any) => class extends superclass { ); }; - getPostThread = async (postId: string) => { + getPostThread = async (postId: string, collapsedThreads = false, collapsedThreadsExtended = false) => { return this.doFetch( - `${this.getPostRoute(postId)}/thread`, + `${this.getPostRoute(postId)}/thread${buildQueryString({collapsedThreads, collapsedThreadsExtended})}`, {method: 'get'}, ); }; - getPosts = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { + getPosts = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT, collapsedThreads = false, collapsedThreadsExtended = false) => { return this.doFetch( - `${this.getChannelRoute(channelId)}/posts${buildQueryString({page, per_page: perPage})}`, + `${this.getChannelRoute(channelId)}/posts${buildQueryString({page, per_page: perPage, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get'}, ); }; - getPostsSince = async (channelId: string, since: number) => { + getPostsSince = async (channelId: string, since: number, collapsedThreads = false, collapsedThreadsExtended = false) => { return this.doFetch( - `${this.getChannelRoute(channelId)}/posts${buildQueryString({since})}`, + `${this.getChannelRoute(channelId)}/posts${buildQueryString({since, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get'}, ); }; - getPostsBefore = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { + getPostsBefore = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT, collapsedThreads = false, collapsedThreadsExtended = false) => { this.analytics.trackAPI('api_posts_get_before', {channel_id: channelId}); return this.doFetch( - `${this.getChannelRoute(channelId)}/posts${buildQueryString({before: postId, page, per_page: perPage})}`, + `${this.getChannelRoute(channelId)}/posts${buildQueryString({before: postId, page, per_page: perPage, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get'}, ); }; - getPostsAfter = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { + getPostsAfter = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT, collapsedThreads = false, collapsedThreadsExtended = false) => { this.analytics.trackAPI('api_posts_get_after', {channel_id: channelId}); return this.doFetch( - `${this.getChannelRoute(channelId)}/posts${buildQueryString({after: postId, page, per_page: perPage})}`, + `${this.getChannelRoute(channelId)}/posts${buildQueryString({after: postId, page, per_page: perPage, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get'}, ); }; diff --git a/app/client/rest/threads.ts b/app/client/rest/threads.ts new file mode 100644 index 0000000000..87224923e6 --- /dev/null +++ b/app/client/rest/threads.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {buildQueryString, isMinimumServerVersion} from '@utils/helpers'; + +import {PER_PAGE_DEFAULT} from './constants'; + +export interface ClientThreadsMix { + getThreads: (userId: string, teamId: string, before?: string, after?: string, pageSize?: number, deleted?: boolean, unread?: boolean, since?: number, serverVersion?: string) => Promise; + getThread: (userId: string, teamId: string, threadId: string, extended?: boolean) => Promise; + updateTeamThreadsAsRead: (userId: string, teamId: string) => Promise; + updateThreadRead: (userId: string, teamId: string, threadId: string, timestamp: number) => Promise; + updateThreadFollow: (userId: string, teamId: string, threadId: string, state: boolean) => Promise; +} + +const ClientThreads = (superclass: any) => class extends superclass { + getThreads = async (userId: string, teamId: string, before = '', after = '', pageSize = PER_PAGE_DEFAULT, deleted = false, unread = false, since = 0, serverVersion = '') => { + const queryStringObj: Record = { + extended: 'true', + before, + after, + deleted, + unread, + since, + }; + if (serverVersion && isMinimumServerVersion(serverVersion, 6, 0)) { + queryStringObj.per_page = pageSize; + } else { + queryStringObj.pageSize = pageSize; + } + return this.doFetch( + `${this.getThreadsRoute(userId, teamId)}${buildQueryString(queryStringObj)}`, + {method: 'get'}, + ); + }; + + getThread = async (userId: string, teamId: string, threadId: string, extended = true) => { + return this.doFetch( + `${this.getThreadRoute(userId, teamId, threadId)}${buildQueryString({extended})}`, + {method: 'get'}, + ); + }; + + updateTeamThreadsAsRead = (userId: string, teamId: string) => { + const url = `${this.getThreadsRoute(userId, teamId)}/read`; + return this.doFetch( + url, + {method: 'put'}, + ); + }; + + updateThreadRead = (userId: string, teamId: string, threadId: string, timestamp: number) => { + const url = `${this.getThreadRoute(userId, teamId, threadId)}/read/${timestamp}`; + return this.doFetch( + url, + {method: 'put'}, + ); + }; + + updateThreadFollow = (userId: string, teamId: string, threadId: string, state: boolean) => { + const url = this.getThreadRoute(userId, teamId, threadId) + '/following'; + return this.doFetch( + url, + {method: state ? 'put' : 'delete'}, + ); + }; +}; + +export default ClientThreads; diff --git a/app/constants/config.ts b/app/constants/config.ts new file mode 100644 index 0000000000..e0787471da --- /dev/null +++ b/app/constants/config.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export default { + ALWAYS_ON: 'always_on', + DEFAULT_ON: 'default_on', + DISABLED: 'disabled', + TRUE: 'true', +}; diff --git a/app/constants/general.ts b/app/constants/general.ts index 5c389fa264..22a58a27ed 100644 --- a/app/constants/general.ts +++ b/app/constants/general.ts @@ -6,6 +6,7 @@ export default { POST_CHUNK_SIZE: 60, POST_AROUND_CHUNK_SIZE: 10, CHANNELS_CHUNK_SIZE: 50, + CRT_CHUNK_SIZE: 60, STATUS_INTERVAL: 60000, AUTOCOMPLETE_LIMIT_DEFAULT: 25, MENTION: 'mention', @@ -30,9 +31,6 @@ export default { MIN_USERS_IN_GM: 3, MAX_GROUP_CHANNELS_FOR_PROFILES: 50, DEFAULT_AUTOLINKED_URL_SCHEMES: ['http', 'https', 'ftp', 'mailto', 'tel', 'mattermost'], - DISABLED: 'disabled', - DEFAULT_ON: 'default_on', - DEFAULT_OFF: 'default_off', PROFILE_CHUNK_SIZE: 100, SEARCH_TIMEOUT_MILLISECONDS: 100, AUTOCOMPLETE_SPLIT_CHARACTERS: ['.', '-', '_'], diff --git a/app/constants/index.ts b/app/constants/index.ts index 72198869ec..0cb2fede4d 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -4,6 +4,7 @@ import ActionType from './action_type'; import Apps from './apps'; import Channel from './channel'; +import Config from './config'; import {CustomStatusDuration} from './custom_status'; import Database from './database'; import DeepLink from './deep_linking'; @@ -30,6 +31,7 @@ import WebsocketEvents from './websocket'; export { ActionType, Apps, + Config, CustomStatusDuration, Channel, Database, diff --git a/app/constants/preferences.ts b/app/constants/preferences.ts index ba419d49a3..b87199f4fa 100644 --- a/app/constants/preferences.ts +++ b/app/constants/preferences.ts @@ -11,6 +11,9 @@ const Preferences: Record = { CATEGORY_FAVORITE_CHANNEL: 'favorite_channel', CATEGORY_AUTO_RESET_MANUAL_STATUS: 'auto_reset_manual_status', CATEGORY_NOTIFICATIONS: 'notifications', + COLLAPSED_REPLY_THREADS: 'collapsed_reply_threads', + COLLAPSED_REPLY_THREADS_OFF: 'off', + COLLAPSED_REPLY_THREADS_ON: 'on', COMMENTS: 'comments', COMMENTS_ANY: 'any', COMMENTS_ROOT: 'root', diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 2eb3747ac5..d949bfe55f 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -8,13 +8,13 @@ export const APP_FORM = 'AppForm'; export const BOTTOM_SHEET = 'BottomSheet'; export const BROWSE_CHANNELS = 'BrowseChannels'; export const CHANNEL = 'Channel'; -export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel'; export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople'; export const CHANNEL_DETAILS = 'ChannelDetails'; export const CHANNEL_EDIT = 'ChannelEdit'; export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage'; -export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter'; +export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel'; export const CUSTOM_STATUS = 'CustomStatus'; +export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter'; export const EDIT_POST = 'EditPost'; export const EDIT_PROFILE = 'EditProfile'; export const EDIT_SERVER = 'EditServer'; @@ -26,6 +26,7 @@ export const IN_APP_NOTIFICATION = 'InAppNotification'; export const LOGIN = 'Login'; export const MENTIONS = 'Mentions'; export const MFA = 'MFA'; +export const PARTICIPANTS_LIST = 'ParticipantsList'; export const PERMALINK = 'Permalink'; export const POST_OPTIONS = 'PostOptions'; export const REACTIONS = 'Reactions'; @@ -35,6 +36,7 @@ export const SERVER = 'Server'; export const SETTINGS_SIDEBAR = 'SettingsSidebar'; export const SSO = 'SSO'; export const THREAD = 'Thread'; +export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; export const USER_PROFILE = 'UserProfile'; export default { @@ -63,6 +65,7 @@ export default { LOGIN, MENTIONS, MFA, + PARTICIPANTS_LIST, PERMALINK, POST_OPTIONS, REACTIONS, @@ -72,6 +75,7 @@ export default { SETTINGS_SIDEBAR, SSO, THREAD, + THREAD_FOLLOW_BUTTON, USER_PROFILE, }; diff --git a/app/database/models/server/thread.ts b/app/database/models/server/thread.ts index a79406d8db..599eb0ebeb 100644 --- a/app/database/models/server/thread.ts +++ b/app/database/models/server/thread.ts @@ -52,6 +52,9 @@ export default class ThreadModel extends Model implements ThreadModelInterface { /** unread_mentions : The number of mentions that have not been read by the user. */ @field('unread_mentions') unreadMentions!: number; + /** viewed_at : The timestamp showing when the user's last opened this thread (this is used for the new line message indicator) */ + @field('viewed_at') viewedAt!: number; + /** participants : All the participants associated with this Thread */ @children(THREAD_PARTICIPANT) participants!: Query; diff --git a/app/database/operator/server_data_operator/handlers/thread.ts b/app/database/operator/server_data_operator/handlers/thread.ts index ddb63a2a73..7187bf164e 100644 --- a/app/database/operator/server_data_operator/handlers/thread.ts +++ b/app/database/operator/server_data_operator/handlers/thread.ts @@ -24,7 +24,7 @@ const { } = Database.MM_TABLES.SERVER; export interface ThreadHandlerMix { - handleThreads: ({threads, prepareRecordsOnly}: HandleThreadsArgs) => Promise; + handleThreads: ({threads, teamId, prepareRecordsOnly}: HandleThreadsArgs) => Promise; handleThreadParticipants: ({threadsParticipants, prepareRecordsOnly}: HandleThreadParticipantsArgs) => Promise; } @@ -79,11 +79,13 @@ const ThreadHandler = (superclass: any) => class extends superclass { const threadParticipants = (await this.handleThreadParticipants({threadsParticipants, prepareRecordsOnly: true})) as ThreadParticipantModel[]; batch.push(...threadParticipants); - const threadsInTeam = await this.handleThreadInTeam({ - threadsMap: {[teamId]: threads}, - prepareRecordsOnly: true, - }) as ThreadInTeamModel[]; - batch.push(...threadsInTeam); + if (teamId) { + const threadsInTeam = await this.handleThreadInTeam({ + threadsMap: {[teamId]: threads}, + prepareRecordsOnly: true, + }) as ThreadInTeamModel[]; + batch.push(...threadsInTeam); + } if (batch.length && !prepareRecordsOnly) { await this.batchRecords(batch); @@ -97,10 +99,11 @@ const ThreadHandler = (superclass: any) => class extends superclass { * @param {HandleThreadParticipantsArgs} handleThreadParticipants * @param {ParticipantsPerThread[]} handleThreadParticipants.threadsParticipants * @param {boolean} handleThreadParticipants.prepareRecordsOnly + * @param {boolean} handleThreadParticipants.skipSync * @throws DataOperatorException * @returns {Promise>} */ - handleThreadParticipants = async ({threadsParticipants, prepareRecordsOnly}: HandleThreadParticipantsArgs): Promise => { + handleThreadParticipants = async ({threadsParticipants, prepareRecordsOnly, skipSync = false}: HandleThreadParticipantsArgs): Promise => { const batchRecords: ThreadParticipantModel[] = []; if (!threadsParticipants.length) { @@ -119,6 +122,7 @@ const ThreadHandler = (superclass: any) => class extends superclass { database: this.database, thread_id, rawParticipants: rawValues, + skipSync, }); if (createParticipants?.length) { diff --git a/app/database/operator/server_data_operator/transformers/thread.ts b/app/database/operator/server_data_operator/transformers/thread.ts index 44dd6e010d..2a4d2bd2a9 100644 --- a/app/database/operator/server_data_operator/transformers/thread.ts +++ b/app/database/operator/server_data_operator/transformers/thread.ts @@ -32,11 +32,12 @@ export const transformThreadRecord = ({action, database, value}: TransformerArgs const fieldsMapper = (thread: ThreadModel) => { thread._raw.id = isCreateAction ? (raw?.id ?? thread.id) : record.id; thread.lastReplyAt = raw.last_reply_at; - thread.lastViewedAt = raw.last_viewed_at; + thread.lastViewedAt = raw.last_viewed_at ?? record?.lastViewedAt ?? 0; thread.replyCount = raw.reply_count; thread.isFollowing = raw.is_following ?? record?.isFollowing; - thread.unreadReplies = raw.unread_replies; - thread.unreadMentions = raw.unread_mentions; + thread.unreadReplies = raw.unread_replies ?? record?.lastViewedAt ?? 0; + thread.unreadMentions = raw.unread_mentions ?? record?.lastViewedAt ?? 0; + thread.viewedAt = record?.viewedAt || 0; }; return prepareBaseRecord({ diff --git a/app/database/operator/utils/thread.ts b/app/database/operator/utils/thread.ts index eced042ae7..e257337758 100644 --- a/app/database/operator/utils/thread.ts +++ b/app/database/operator/utils/thread.ts @@ -4,6 +4,7 @@ import {Q} from '@nozbe/watermelondb'; import {MM_TABLES} from '@constants/database'; +import type {Clause} from '@nozbe/watermelondb/QueryDescription'; import type {RecordPair, SanitizeThreadParticipantsArgs} from '@typings/database/database'; import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant'; @@ -18,10 +19,20 @@ const {THREAD_PARTICIPANT} = MM_TABLES.SERVER; * @param {UserProfile[]} sanitizeThreadParticipants.rawParticipants * @returns {Promise<{createParticipants: ThreadParticipant[], deleteParticipants: ThreadParticipantModel[]}>} */ -export const sanitizeThreadParticipants = async ({database, thread_id, rawParticipants}: SanitizeThreadParticipantsArgs) => { +export const sanitizeThreadParticipants = async ({database, skipSync, thread_id, rawParticipants}: SanitizeThreadParticipantsArgs) => { + const clauses: Clause[] = [Q.where('thread_id', thread_id)]; + + // Check if we already have the participants + if (skipSync) { + clauses.push( + Q.where('user_id', Q.oneOf( + rawParticipants.map((participant) => participant.id), + )), + ); + } const participants = (await database.collections. get(THREAD_PARTICIPANT). - query(Q.where('thread_id', thread_id)). + query(...clauses). fetch()) as ThreadParticipantModel[]; // similarObjects: Contains objects that are in both the RawParticipant array and in the ThreadParticipant table @@ -42,6 +53,10 @@ export const sanitizeThreadParticipants = async ({database, thread_id, rawPartic } } + if (skipSync) { + return {createParticipants, deleteParticipants: []}; + } + // finding out elements to delete using array subtract const deleteParticipants = participants. filter((participant) => !similarObjects.includes(participant)). diff --git a/app/database/schema/server/table_schemas/thread.ts b/app/database/schema/server/table_schemas/thread.ts index 87c93f2b78..30c8150903 100644 --- a/app/database/schema/server/table_schemas/thread.ts +++ b/app/database/schema/server/table_schemas/thread.ts @@ -16,5 +16,6 @@ export default tableSchema({ {name: 'reply_count', type: 'number'}, {name: 'unread_replies', type: 'number'}, {name: 'unread_mentions', type: 'number'}, + {name: 'viewed_at', type: 'number'}, ], }); diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index c7de6d726f..e950a0bb5e 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -436,6 +436,7 @@ describe('*** Test schema for SERVER database ***', () => { reply_count: {name: 'reply_count', type: 'number'}, unread_replies: {name: 'unread_replies', type: 'number'}, unread_mentions: {name: 'unread_mentions', type: 'number'}, + viewed_at: {name: 'viewed_at', type: 'number'}, }, columnArray: [ {name: 'last_reply_at', type: 'number'}, @@ -444,6 +445,7 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'reply_count', type: 'number'}, {name: 'unread_replies', type: 'number'}, {name: 'unread_mentions', type: 'number'}, + {name: 'viewed_at', type: 'number'}, ], }, [THREAD_PARTICIPANT]: { diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index 95e2f3adb4..40ddceb7f1 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -142,7 +142,7 @@ export const queryPostsBetween = (database: Database, earliest: number, latest: andClauses.push(Q.where('user_id', userId)); } - if (rootId) { + if (rootId != null) { andClauses.push(Q.where('root_id', rootId)); } diff --git a/app/queries/servers/thread.ts b/app/queries/servers/thread.ts new file mode 100644 index 0000000000..e6b78d27aa --- /dev/null +++ b/app/queries/servers/thread.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database, Q, Query} from '@nozbe/watermelondb'; +import {combineLatest, of as of$} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; + +import {Preferences} from '@constants'; +import {MM_TABLES} from '@constants/database'; +import {isCRTEnabled} from '@utils/thread'; + +import {queryPreferencesByCategoryAndName} from './preference'; +import {getConfig, observeConfig} from './system'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type Model from '@nozbe/watermelondb/Model'; +import type ThreadModel from '@typings/database/models/servers/thread'; + +const {SERVER: {CHANNEL, POST, THREAD}} = MM_TABLES; + +export const getIsCRTEnabled = async (database: Database): Promise => { + const config = await getConfig(database); + const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).fetch(); + return isCRTEnabled(preferences, config); +}; + +export const getThreadById = async (database: Database, threadId: string) => { + try { + const thread = await database.get(THREAD).find(threadId); + return thread; + } catch { + return undefined; + } +}; + +export const observeIsCRTEnabled = (database: Database) => { + getConfig(database); + const config = observeConfig(database); + const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).observe(); + return combineLatest([config, preferences]).pipe( + map( + ([cfg, prefs]) => isCRTEnabled(prefs, cfg), + ), + ); +}; + +export const observeThreadById = (database: Database, threadId: string) => { + return database.get(THREAD).query( + Q.where('id', threadId), + ).observe().pipe( + switchMap((threads) => threads[0]?.observe() || of$(undefined)), + ); +}; + +export const observeUnreadsAndMentionsInTeam = (database: Database, teamId: string) => { + return queryThreadsInTeam(database, teamId, true).observeWithColumns(['unread_replies', 'unread_mentions']).pipe( + switchMap((threads) => { + let unreads = 0; + let mentions = 0; + threads.forEach((thread) => { + unreads += thread.unreadReplies; + mentions += thread.unreadMentions; + }); + return of$({unreads, mentions}); + }), + ); +}; + +// On receiving "posts", Save the "root posts" as "threads" +export const prepareThreadsFromReceivedPosts = async (operator: ServerDataOperator, posts: Post[]) => { + const models: Model[] = []; + const threads: Thread[] = []; + posts.forEach((post: Post) => { + if (!post.root_id && post.type === '') { + threads.push({ + id: post.id, + participants: post.participants, + reply_count: post.reply_count, + last_reply_at: post.last_reply_at, + is_following: post.is_following, + } as Thread); + } + }); + if (threads.length) { + const threadModels = await operator.handleThreads({threads, prepareRecordsOnly: true}); + if (threadModels.length) { + models.push(...threadModels); + } + } + + return models; +}; + +export const queryThreadsInTeam = (database: Database, teamId: string, onlyUnreads?: boolean, hasReplies?: boolean, isFollowing?: boolean, sort?: boolean): Query => { + const query: Q.Clause[] = [ + Q.experimentalNestedJoin(POST, CHANNEL), + ]; + + if (isFollowing) { + query.push(Q.where('is_following', true)); + } + + if (hasReplies) { + query.push(Q.where('reply_count', Q.gt(0))); + } + + if (onlyUnreads) { + query.push(Q.where('unread_replies', Q.gt(0))); + } + + if (sort) { + query.push(Q.sortBy('last_reply_at', Q.desc)); + } + + query.push( + Q.on( + POST, + Q.on( + CHANNEL, + Q.or( + Q.where('team_id', teamId), + Q.where('team_id', ''), + ), + ), + ), + ); + + return database.get(THREAD).query(...query); +}; diff --git a/app/screens/channel/channel_post_list/index.ts b/app/screens/channel/channel_post_list/index.ts index 2e048a339b..2a30975729 100644 --- a/app/screens/channel/channel_post_list/index.ts +++ b/app/screens/channel/channel_post_list/index.ts @@ -6,7 +6,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import React from 'react'; import {AppStateStatus} from 'react-native'; -import {of as of$} from 'rxjs'; +import {combineLatest, of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import {Preferences} from '@constants'; @@ -15,6 +15,7 @@ import {observeMyChannel} from '@queries/servers/channel'; import {queryPostsBetween, queryPostsInChannel} from '@queries/servers/post'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {observeConfigBooleanValue} from '@queries/servers/system'; +import {observeIsCRTEnabled} from '@queries/servers/thread'; import {observeCurrentUser} from '@queries/servers/user'; import {getTimezone} from '@utils/user'; @@ -25,21 +26,25 @@ import type {WithDatabaseArgs} from '@typings/database/database'; const enhanced = withObservables(['channelId', 'forceQueryAfterAppState'], ({database, channelId}: {channelId: string; forceQueryAfterAppState: AppStateStatus} & WithDatabaseArgs) => { const currentUser = observeCurrentUser(database); + const isCRTEnabledObserver = observeIsCRTEnabled(database); + const postsInChannelObserver = queryPostsInChannel(database, channelId).observeWithColumns(['earliest', 'latest']); + return { currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))), currentUsername: currentUser.pipe((switchMap((user) => of$(user?.username)))), + isCRTEnabled: isCRTEnabledObserver, isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'), lastViewedAt: observeMyChannel(database, channelId).pipe( switchMap((myChannel) => of$(myChannel?.viewedAt)), ), - posts: queryPostsInChannel(database, channelId).observeWithColumns(['earliest', 'latest']).pipe( - switchMap((postsInChannel) => { + posts: combineLatest([isCRTEnabledObserver, postsInChannelObserver]).pipe( + switchMap(([isCRTEnabled, postsInChannel]) => { if (!postsInChannel.length) { return of$([]); } const {earliest, latest} = postsInChannel[0]; - return queryPostsBetween(database, earliest, latest, Q.desc, '', channelId).observe(); + return queryPostsBetween(database, earliest, latest, Q.desc, '', channelId, isCRTEnabled ? '' : undefined).observe(); }), ), shouldShowJoinLeaveMessages: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE).observe().pipe( diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 2cdf56d267..ff64a41308 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -145,6 +145,11 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.THREAD: screen = withServerDatabase(require('@screens/thread').default); break; + case Screens.THREAD_FOLLOW_BUTTON: + Navigation.registerComponent(Screens.THREAD_FOLLOW_BUTTON, () => withServerDatabase( + require('@screens/thread/thread_follow_button').default, + )); + break; } if (screen) { diff --git a/app/screens/thread/thread.tsx b/app/screens/thread/thread.tsx index 9cf6e5d0e7..0ba4bdd513 100644 --- a/app/screens/thread/thread.tsx +++ b/app/screens/thread/thread.tsx @@ -42,7 +42,6 @@ const Thread = ({rootPost}: ThreadProps) => { <> { + return { + isFollowing: observeThreadById(database, threadId).pipe( + switchMap((thread) => of$(thread?.isFollowing)), + ), + }; +}); + +export default withDatabase(enhanced(ThreadFollowButton)); diff --git a/app/screens/thread/thread_follow_button/thread_follow_button.tsx b/app/screens/thread/thread_follow_button/thread_follow_button.tsx new file mode 100644 index 0000000000..a7f61ed545 --- /dev/null +++ b/app/screens/thread/thread_follow_button/thread_follow_button.tsx @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Platform, StyleSheet, TouchableOpacity, View} from 'react-native'; + +import {updateThreadFollowing} from '@actions/remote/thread'; +import FormattedText from '@components/formatted_text'; +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'; + +type Props = { + isFollowing: boolean; + teamId: string; + threadId: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + borderColor: theme.sidebarHeaderTextColor, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 4, + paddingVertical: 4.5, + paddingHorizontal: 10, + opacity: 0.72, + ...Platform.select({ + android: { + marginRight: 12, + }, + ios: { + right: -4, + }, + }), + }, + containerActive: { + backgroundColor: changeOpacity(theme.sidebarHeaderTextColor, 0.24), + borderColor: 'transparent', + opacity: 1, + }, + text: { + color: theme.sidebarHeaderTextColor, + ...typography('Heading', 75, 'SemiBold'), + }, + }; +}); + +function ThreadFollow({isFollowing, teamId, threadId}: Props) { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const serverUrl = useServerUrl(); + + const onPress = preventDoubleTap(() => { + updateThreadFollowing(serverUrl, teamId, threadId, !isFollowing); + }); + + const containerStyle = [styles.container]; + let followTextProps = { + id: 'threads.follow', + defaultMessage: 'Follow', + }; + if (isFollowing) { + containerStyle.push(styles.containerActive); + followTextProps = { + id: 'threads.following', + defaultMessage: 'Following', + }; + } + + return ( + + + + + + ); +} + +export default ThreadFollow; diff --git a/app/screens/thread/thread_post_list/index.ts b/app/screens/thread/thread_post_list/index.ts index f634311410..8e9956d258 100644 --- a/app/screens/thread/thread_post_list/index.ts +++ b/app/screens/thread/thread_post_list/index.ts @@ -10,6 +10,7 @@ import {switchMap} from 'rxjs/operators'; import {observeMyChannel} from '@queries/servers/channel'; import {queryPostsChunk, queryPostsInThread} from '@queries/servers/post'; import {observeConfigBooleanValue} from '@queries/servers/system'; +import {observeIsCRTEnabled} from '@queries/servers/thread'; import {observeCurrentUser} from '@queries/servers/user'; import {getTimezone} from '@utils/user'; @@ -19,19 +20,19 @@ import type {WithDatabaseArgs} from '@typings/database/database'; import type PostModel from '@typings/database/models/servers/post'; type Props = WithDatabaseArgs & { - channelId: string; forceQueryAfterAppState: AppStateStatus; rootPost: PostModel; }; -const enhanced = withObservables(['channelId', 'forceQueryAfterAppState', 'rootPost'], ({channelId, database, rootPost}: Props) => { +const enhanced = withObservables(['forceQueryAfterAppState', 'rootPost'], ({database, rootPost}: Props) => { const currentUser = observeCurrentUser(database); return { currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))), currentUsername: currentUser.pipe((switchMap((user) => of$(user?.username || '')))), + isCRTEnabled: observeIsCRTEnabled(database), isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'), - lastViewedAt: observeMyChannel(database, channelId).pipe( + lastViewedAt: observeMyChannel(database, rootPost.channelId).pipe( switchMap((myChannel) => of$(myChannel?.viewedAt)), ), posts: queryPostsInThread(database, rootPost.id, true, true).observeWithColumns(['earliest', 'latest']).pipe( @@ -44,6 +45,9 @@ const enhanced = withObservables(['channelId', 'forceQueryAfterAppState', 'rootP return queryPostsChunk(database, rootPost.id, earliest, latest, true).observe(); }), ), + teamId: rootPost.channel.observe().pipe( + switchMap((channel) => of$(channel?.teamId)), + ), }; }); diff --git a/app/screens/thread/thread_post_list/thread_post_list.tsx b/app/screens/thread/thread_post_list/thread_post_list.tsx index 0d2a6ca4f5..df8fb4a940 100644 --- a/app/screens/thread/thread_post_list/thread_post_list.tsx +++ b/app/screens/thread/thread_post_list/thread_post_list.tsx @@ -1,25 +1,28 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useMemo} from 'react'; +import React, {useEffect, useMemo, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; import {Edge, SafeAreaView} from 'react-native-safe-area-context'; +import {updateThreadRead} from '@actions/remote/thread'; import PostList from '@components/post_list'; import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; import {useIsTablet} from '@hooks/device'; import type PostModel from '@typings/database/models/servers/post'; type Props = { - channelId: string; currentTimezone: string | null; currentUsername: string; + isCRTEnabled: boolean; isTimezoneEnabled: boolean; lastViewedAt: number; nativeID: string; posts: PostModel[]; rootPost: PostModel; + teamId: string; } const edges: Edge[] = ['bottom']; @@ -31,18 +34,28 @@ const styles = StyleSheet.create({ }); const ThreadPostList = ({ - channelId, currentTimezone, currentUsername, - isTimezoneEnabled, lastViewedAt, nativeID, posts, rootPost, + currentTimezone, currentUsername, + isCRTEnabled, isTimezoneEnabled, lastViewedAt, nativeID, posts, rootPost, teamId, }: Props) => { const isTablet = useIsTablet(); + const serverUrl = useServerUrl(); const threadPosts = useMemo(() => { return [...posts, rootPost]; }, [posts, rootPost]); + // If CRT is enabled, When new post arrives and thread modal is open, mark thread as read + const oldPostsCount = useRef(posts.length); + useEffect(() => { + if (isCRTEnabled && oldPostsCount.current < posts.length) { + oldPostsCount.current = posts.length; + updateThreadRead(serverUrl, teamId, rootPost.id, Date.now()); + } + }, [isCRTEnabled, posts, rootPost, serverUrl, teamId]); + const postList = ( { return -1; }); }; + +export const processPostsFetched = (data: PostResponse) => { + const order = data.order; + const posts = Object.values(data.posts) as Post[]; + const previousPostId = data.prev_post_id; + + return { + posts, + order, + previousPostId, + }; +}; diff --git a/app/utils/thread/index.ts b/app/utils/thread/index.ts new file mode 100644 index 0000000000..142bb7a4c7 --- /dev/null +++ b/app/utils/thread/index.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Config, Preferences} from '@constants'; +import {getPreferenceValue} from '@helpers/api/preference'; + +import type PreferenceModel from '@typings/database/models/servers/preference'; + +export function isCRTEnabled(preferences: PreferenceModel[], config?: ClientConfig): boolean { + let preferenceDefault = Preferences.COLLAPSED_REPLY_THREADS_OFF; + const configValue = config?.CollapsedThreads; + if (configValue === Config.DEFAULT_ON) { + preferenceDefault = Preferences.COLLAPSED_REPLY_THREADS_ON; + } + const preference = getPreferenceValue(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.COLLAPSED_REPLY_THREADS, preferenceDefault); + + const isAllowed = ( + config?.FeatureFlagCollapsedThreads === Config.TRUE && + config?.CollapsedThreads !== Config.DISABLED + ); + + return isAllowed && ( + preference === Preferences.COLLAPSED_REPLY_THREADS_ON || + config?.CollapsedThreads === Config.ALWAYS_ON + ); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 6cedcf8342..aa3412015c 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -485,6 +485,8 @@ "thread.header.thread_in": "in {channelName}", "thread.noReplies": "No replies yet", "thread.repliesCount": "{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}", + "threads.follow": "Follow", + "threads.following": "Following", "threads.followMessage": "Follow Message", "threads.followThread": "Follow Thread", "threads.unfollowMessage": "Unfollow Message", diff --git a/types/api/channels.d.ts b/types/api/channels.d.ts index ffd594de2e..2447c4444a 100644 --- a/types/api/channels.d.ts +++ b/types/api/channels.d.ts @@ -55,7 +55,9 @@ type ChannelMembership = { roles: string; last_viewed_at: number; msg_count: number; + msg_count_root?: number; mention_count: number; + mention_count_root?: number; notify_props: Partial; last_post_at?: number; last_update_at: number; diff --git a/types/api/threads.d.ts b/types/api/threads.d.ts index 44faa7d05a..0619d4dd63 100644 --- a/types/api/threads.d.ts +++ b/types/api/threads.d.ts @@ -18,3 +18,10 @@ type ThreadParticipant = { id: $ID; thread_id: $ID; }; + +type GetUserThreadsResponse = { + threads: Thread[]; + total: number; + total_unread_mentions: number; + total_unread_threads: number; +}; diff --git a/types/api/websocket.d.ts b/types/api/websocket.d.ts index 86ef975b04..c1f4b8db7e 100644 --- a/types/api/websocket.d.ts +++ b/types/api/websocket.d.ts @@ -14,3 +14,10 @@ type WebSocketMessage = { broadcast: WebsocketBroadcast; seq: number; } + +type ThreadReadChangedData = { + thread_id: string; + timestamp: number; + unread_mentions: number; + unread_replies: number; +}; diff --git a/types/database/database.d.ts b/types/database/database.d.ts index c6075e4f8e..3cb8e40347 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -91,11 +91,12 @@ export type HandlePostsArgs = { export type HandleThreadsArgs = { threads: Thread[]; prepareRecordsOnly?: boolean; - teamId: string; + teamId?: string; }; export type HandleThreadParticipantsArgs = { prepareRecordsOnly: boolean; + skipSync?: boolean; threadsParticipants: ParticipantsPerThread[]; }; @@ -113,6 +114,7 @@ export type SanitizeReactionsArgs = { export type SanitizeThreadParticipantsArgs = { database: Database; + skipSync?: boolean; thread_id: $ID; rawParticipants: ThreadParticipant[]; } diff --git a/types/database/models/servers/thread.d.ts b/types/database/models/servers/thread.d.ts index 02f399736d..95727690a8 100644 --- a/types/database/models/servers/thread.d.ts +++ b/types/database/models/servers/thread.d.ts @@ -35,6 +35,9 @@ export default class ThreadModel extends Model { /** unread_mentions : The number of mentions that are not read by the user. */ unreadMentions: number; + /** viewed_at : The timestamp showing when the user's last opened this thread (this is used for the new line message indicator) */ + viewedAt: number; + /** participants: All the participants of the thread */ participants: Query;