diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 02bff3f189..db5fa13387 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -36,7 +36,6 @@ export const sendAddToChannelEphemeralPost = async (serverUrl: string, user: Use pending_post_id: '', reply_count: 0, metadata: {}, - participants: null, root_id: postRootId, props: { username: user.username, diff --git a/app/constants/database.ts b/app/constants/database.ts index 8a818a9c82..74b14641f2 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -34,6 +34,8 @@ export const MM_TABLES = { TEAM_MEMBERSHIP: 'TeamMembership', TEAM_SEARCH_HISTORY: 'TeamSearchHistory', TERMS_OF_SERVICE: 'TermsOfService', + THREAD: 'Thread', + THREAD_PARTICIPANT: 'ThreadParticipant', USER: 'User', }, }; diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 8fe94f81ae..24b4bbe2a6 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -15,7 +15,7 @@ import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, Cha MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, - TermsOfServiceModel, UserModel, + TermsOfServiceModel, ThreadModel, ThreadParticipantModel, UserModel, } from '@database/models/server'; import AppDataOperator from '@database/operator/app_data_operator'; import ServerDataOperator from '@database/operator/server_data_operator'; @@ -51,7 +51,7 @@ class DatabaseManager { MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, - TermsOfServiceModel, UserModel, + TermsOfServiceModel, ThreadModel, ThreadParticipantModel, UserModel, ]; this.databaseDirectory = ''; } diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index c04a44fb5f..fba3ee382c 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -16,7 +16,7 @@ import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, Cha MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, - TermsOfServiceModel, UserModel, + TermsOfServiceModel, ThreadModel, ThreadParticipantModel, UserModel, } from '@database/models/server'; import AppDataOperator from '@database/operator/app_data_operator'; import ServerDataOperator from '@database/operator/server_data_operator'; @@ -46,7 +46,7 @@ class DatabaseManager { MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, - TermsOfServiceModel, UserModel, + TermsOfServiceModel, ThreadModel, ThreadParticipantModel, UserModel, ]; this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${FileSystem.documentDirectory}databases/`; diff --git a/app/database/models/server/index.ts b/app/database/models/server/index.ts index ef6b0296b6..6167ac32dc 100644 --- a/app/database/models/server/index.ts +++ b/app/database/models/server/index.ts @@ -25,4 +25,6 @@ export {default as TeamMembershipModel} from './team_membership'; export {default as TeamModel} from './team'; export {default as TeamSearchHistoryModel} from './team_search_history'; export {default as TermsOfServiceModel} from './terms_of_service'; +export {default as ThreadModel} from './thread'; +export {default as ThreadParticipantModel} from './thread_participant'; export {default as UserModel} from './user'; diff --git a/app/database/models/server/post.ts b/app/database/models/server/post.ts index 56e8b1e062..e0d7caeb86 100644 --- a/app/database/models/server/post.ts +++ b/app/database/models/server/post.ts @@ -13,9 +13,10 @@ import type DraftModel from '@typings/database/models/servers/draft'; import type FileModel from '@typings/database/models/servers/file'; import type PostInThreadModel from '@typings/database/models/servers/posts_in_thread'; import type ReactionModel from '@typings/database/models/servers/reaction'; +import type ThreadModel from '@typings/database/models/servers/thread'; import type UserModel from '@typings/database/models/servers/user'; -const {CHANNEL, DRAFT, FILE, POST, POSTS_IN_THREAD, REACTION, USER} = MM_TABLES.SERVER; +const {CHANNEL, DRAFT, FILE, POST, POSTS_IN_THREAD, REACTION, THREAD, USER} = MM_TABLES.SERVER; /** * The Post model is the building block of communication in the Mattermost app. @@ -42,6 +43,9 @@ export default class PostModel extends Model { /** A POST can have multiple REACTION. (relationship is 1:N)*/ [REACTION]: {type: 'has_many', foreignKey: 'post_id'}, + /** A POST can have an associated thread. (relationship is 1:1) */ + [THREAD]: {type: 'has_many', foreignKey: 'id'}, + /** A USER can have multiple POST. A user can author several posts. (relationship is 1:N)*/ [USER]: {type: 'belongs_to', key: 'user_id'}, }; @@ -115,6 +119,9 @@ export default class PostModel extends Model { /** channel: The channel which is presenting this Post */ @immutableRelation(CHANNEL, 'channel_id') channel!: Relation; + /** thread : The thread data for the post */ + @immutableRelation(THREAD, 'id') thread!: Relation; + async destroyPermanently() { await this.reactions.destroyAllPermanently(); await this.files.destroyAllPermanently(); @@ -122,6 +129,11 @@ export default class PostModel extends Model { await this.collections.get(POSTS_IN_THREAD).query( Q.where('root_id', this.id), ).destroyAllPermanently(); + try { + (await this.thread.fetch())?.destroyPermanently(); + } catch { + // there is no thread record for this post + } super.destroyPermanently(); } diff --git a/app/database/models/server/thread.ts b/app/database/models/server/thread.ts new file mode 100644 index 0000000000..cd98dd93ac --- /dev/null +++ b/app/database/models/server/thread.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Query, Relation} from '@nozbe/watermelondb'; +import {children, field, immutableRelation} from '@nozbe/watermelondb/decorators'; +import Model, {Associations} from '@nozbe/watermelondb/Model'; + +import {MM_TABLES} from '@constants/database'; + +import type PostModel from '@typings/database/models/servers/post'; +import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant'; + +const {POST, THREAD, THREAD_PARTICIPANT} = MM_TABLES.SERVER; + +/** + * The Thread model contains thread information of a post. + */ +export default class ThreadModel extends Model { + /** table (name) : Thread */ + static table = THREAD; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + + /** A THREAD is associated to one POST (relationship is 1:1) */ + [POST]: {type: 'belongs_to', key: 'id'}, + + /** A THREAD can have multiple THREAD_PARTICIPANT. (relationship is 1:N)*/ + [THREAD_PARTICIPANT]: {type: 'has_many', foreignKey: 'thread_id'}, + }; + + /** last_reply_at : The timestamp of when user last replied to the thread. */ + @field('last_reply_at') lastReplyAt!: number; + + /** last_viewed_at : The timestamp of when user last viewed the thread. */ + @field('last_viewed_at') lastViewedAt!: number; + + /** reply_count : The total replies to the thread by all the participants. */ + @field('reply_count') replyCount!: number; + + /** is_following: If user is following the thread or not */ + @field('is_following') isFollowing!: boolean; + + /** unread_replies : The number of replies that have not been read by the user. */ + @field('unread_replies') unreadReplies!: number; + + /** unread_mentions : The number of mentions that have not been read by the user. */ + @field('unread_mentions') unreadMentions!: number; + + /** loaded_in_global_threads : Flag to differentiate the unread threads loaded for showing unread counts/mentions */ + @field('loaded_in_global_threads') loadedInGlobalThreads!: boolean; + + /** participants : All the participants associated with this Thread */ + @children(THREAD_PARTICIPANT) participants!: Query; + + /** post : The root post of this thread */ + @immutableRelation(POST, 'id') post!: Relation; + + async destroyPermanently() { + await this.participants.destroyAllPermanently(); + super.destroyPermanently(); + } +} diff --git a/app/database/models/server/thread_participant.ts b/app/database/models/server/thread_participant.ts new file mode 100644 index 0000000000..d5951fd9a3 --- /dev/null +++ b/app/database/models/server/thread_participant.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Relation} from '@nozbe/watermelondb'; +import {field, immutableRelation} from '@nozbe/watermelondb/decorators'; +import Model, {Associations} from '@nozbe/watermelondb/Model'; + +import {MM_TABLES} from '@constants/database'; + +import type ThreadModel from '@typings/database/models/servers/thread'; +import type UserModel from '@typings/database/models/servers/user'; + +const {THREAD, THREAD_PARTICIPANT, USER} = MM_TABLES.SERVER; + +/** + * The Thread Participants model contains participants data of a thread. + */ +export default class ThreadParticipantModel extends Model { + /** table (name) : ThreadParticipant */ + static table = THREAD_PARTICIPANT; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + + /** A THREAD can have multiple PARTICIPANTS. (relationship is 1:N) */ + [THREAD]: {type: 'belongs_to', key: 'thread_id'}, + + /** A USER can participate in multiple THREADS. (relationship is 1:N) */ + [USER]: {type: 'belongs_to', key: 'user_id'}, + }; + + /** thread_id : thread id to which participant belong to. */ + @field('thread_id') threadId!: string; + + /** user_id : user id of the participant. */ + @field('user_id') userId!: number; + + /** thread : The related record of the Thread model */ + @immutableRelation(THREAD, 'thread_id') thread!: Relation; + + /** user : The related record of the User model */ + @immutableRelation(USER, 'user_id') user!: Relation; +} diff --git a/app/database/models/server/user.ts b/app/database/models/server/user.ts index b4bf642515..bc85231840 100644 --- a/app/database/models/server/user.ts +++ b/app/database/models/server/user.ts @@ -5,6 +5,7 @@ import {children, field, json} from '@nozbe/watermelondb/decorators'; import Model, {Associations} from '@nozbe/watermelondb/Model'; import {MM_TABLES} from '@constants/database'; +import ThreadParticipantsModel from '@typings/database/models/servers/thread_participant'; import {safeParseJSON} from '@utils/helpers'; import type ChannelModel from '@typings/database/models/servers/channel'; @@ -22,6 +23,7 @@ const { PREFERENCE, REACTION, TEAM_MEMBERSHIP, + THREAD_PARTICIPANT, USER, } = MM_TABLES.SERVER; @@ -53,6 +55,9 @@ export default class UserModel extends Model { /** USER has a 1:N relationship with TEAM_MEMBERSHIP. A user can join multiple teams */ [TEAM_MEMBERSHIP]: {type: 'has_many', foreignKey: 'user_id'}, + + /** USER has a 1:N relationship with THREAD_PARTICIPANT. A user can participante in multiple threads */ + [THREAD_PARTICIPANT]: {type: 'has_many', foreignKey: 'user_id'}, }; /** auth_service : The type of authentication service registered to that user */ @@ -129,6 +134,9 @@ export default class UserModel extends Model { /** teams : All the team that this user is part of */ @children(TEAM_MEMBERSHIP) teams!: TeamMembershipModel[]; + /** threadParticipations : All the thread participations this user is part of */ + @children(THREAD_PARTICIPANT) threadParticipations!: ThreadParticipantsModel[]; + prepareStatus = (status: string) => { this.prepareUpdate((u) => { u.status = status; diff --git a/app/database/operator/server_data_operator/comparators/index.ts b/app/database/operator/server_data_operator/comparators/index.ts index 1f49eff5b7..8fd532cc1a 100644 --- a/app/database/operator/server_data_operator/comparators/index.ts +++ b/app/database/operator/server_data_operator/comparators/index.ts @@ -22,6 +22,7 @@ import type TeamChannelHistoryModel from '@typings/database/models/servers/team_ import type TeamMembershipModel from '@typings/database/models/servers/team_membership'; import type TeamSearchHistoryModel from '@typings/database/models/servers/team_search_history'; import type TermsOfServiceModel from '@typings/database/models/servers/terms_of_service'; +import type ThreadModel from '@typings/database/models/servers/thread'; import type UserModel from '@typings/database/models/servers/user'; /** @@ -122,3 +123,7 @@ export const isRecordMyChannelEqualToRaw = (record: MyChannelModel, raw: Channel export const isRecordFileEqualToRaw = (record: FileModel, raw: FileInfo) => { return raw.id === record.id; }; + +export const isRecordThreadEqualToRaw = (record: ThreadModel, raw: Thread) => { + return raw.id === record.id; +}; diff --git a/app/database/operator/server_data_operator/handlers/post.test.ts b/app/database/operator/server_data_operator/handlers/post.test.ts index 6d645d56a9..1a4f5c4a09 100644 --- a/app/database/operator/server_data_operator/handlers/post.test.ts +++ b/app/database/operator/server_data_operator/handlers/post.test.ts @@ -75,6 +75,7 @@ describe('*** Operator: Post Handlers tests ***', () => { edit_at: 0, delete_at: 0, is_pinned: false, + is_following: false, user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', root_id: '', @@ -169,6 +170,7 @@ describe('*** Operator: Post Handlers tests ***', () => { edit_at: 0, delete_at: 0, is_pinned: false, + is_following: false, user_id: 'hy5sq51sebfh58ktrce5ijtcwyy', channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', root_id: '8swgtrrdiff89jnsiwiip3y1eoe', @@ -195,6 +197,7 @@ describe('*** Operator: Post Handlers tests ***', () => { edit_at: 0, delete_at: 0, is_pinned: false, + is_following: false, user_id: '44ud4m9tqwby3mphzzdwm7h31sr', channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', root_id: '8swgtrrdiff89jnsiwiip3y1eoe', diff --git a/app/database/operator/server_data_operator/handlers/thread.test.ts b/app/database/operator/server_data_operator/handlers/thread.test.ts new file mode 100644 index 0000000000..f4311b98c9 --- /dev/null +++ b/app/database/operator/server_data_operator/handlers/thread.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import {isRecordThreadEqualToRaw} from '@database/operator/server_data_operator/comparators'; +import {transformThreadRecord, transformThreadParticipantRecord} from '@database/operator/server_data_operator/transformers/thread'; + +import ServerDataOperator from '..'; + +jest.mock('@database/operator/utils/thread', () => { + return { + sanitizeThreadParticipants: ({rawParticipants}: {rawParticipants: ThreadParticipant[]}) => { + return { + createParticipants: rawParticipants.map((participant) => ({ + raw: participant, + })), + }; + }, + }; +}); + +describe('*** Operator: Thread Handlers tests ***', () => { + let operator: ServerDataOperator; + + beforeAll(async () => { + await DatabaseManager.init(['baseHandler.test.com']); + operator = DatabaseManager.serverDatabases['baseHandler.test.com'].operator; + }); + + it('=> HandleThreads: should write to the the Thread & ThreadParticipant table', async () => { + const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords'); + const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords'); + const spyOnHandleThreadParticipants = jest.spyOn(operator, 'handleThreadParticipants'); + + const threads = [ + { + id: 'thread-1', + reply_count: 2, + last_reply_at: 123, + last_viewed_at: 123, + participants: [{ + id: 'user-1', + }], + is_following: true, + unread_replies: 0, + unread_mentions: 0, + }, + ] as Thread[]; + + await operator.handleThreads({threads, prepareRecordsOnly: false}); + + expect(spyOnHandleRecords).toHaveBeenCalledWith({ + findMatchingRecordBy: isRecordThreadEqualToRaw, + fieldName: 'id', + transformer: transformThreadRecord, + createOrUpdateRawValues: threads, + tableName: 'Thread', + prepareRecordsOnly: true, + }); + + // Should handle participants + expect(spyOnHandleThreadParticipants).toHaveBeenCalledWith({ + threadsParticipants: threads.map((thread) => ({ + thread_id: thread.id, + participants: thread.participants.map((participant) => ({ + id: participant.id, + thread_id: thread.id, + })), + })), + prepareRecordsOnly: true, + }); + + // Only one batch operation for both tables + expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); + }); + + it('=> HandleThreadParticipants: should write to the the ThreadParticipant table', async () => { + const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); + + const threadsParticipants = [ + { + thread_id: 'thread-1', + participants: [{ + id: 'user-1', + thread_id: 'thread-1', + }], + }, + ]; + + await operator.handleThreadParticipants({threadsParticipants, prepareRecordsOnly: false}); + + expect(spyOnPrepareRecords).toHaveBeenCalledWith({ + createRaws: [{ + raw: threadsParticipants[0].participants[0], + }], + transformer: transformThreadParticipantRecord, + tableName: 'ThreadParticipant', + }); + }); +}); diff --git a/app/database/operator/server_data_operator/handlers/thread.ts b/app/database/operator/server_data_operator/handlers/thread.ts new file mode 100644 index 0000000000..99830daa39 --- /dev/null +++ b/app/database/operator/server_data_operator/handlers/thread.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Model from '@nozbe/watermelondb/Model'; + +import {Database} from '@constants'; +import DataOperatorException from '@database/exceptions/data_operator_exception'; +import {isRecordThreadEqualToRaw} from '@database/operator/server_data_operator/comparators'; +import { + transformThreadRecord, + transformThreadParticipantRecord, +} from '@database/operator/server_data_operator/transformers/thread'; +import {getUniqueRawsBy} from '@database/operator/utils/general'; +import {sanitizeThreadParticipants} from '@database/operator/utils/thread'; + +import type {HandleThreadsArgs, HandleThreadParticipantsArgs} from '@typings/database/database'; +import type ThreadModel from '@typings/database/models/servers/thread'; +import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant'; + +const { + THREAD, + THREAD_PARTICIPANT, +} = Database.MM_TABLES.SERVER; + +export interface ThreadHandlerMix { + handleThreads: ({threads, prepareRecordsOnly}: HandleThreadsArgs) => Promise; + handleThreadParticipants: ({threadsParticipants, prepareRecordsOnly}: HandleThreadParticipantsArgs) => Promise; +} + +const ThreadHandler = (superclass: any) => class extends superclass { + /** + * handleThreads: Handler responsible for the Create/Update operations occurring on the Thread table from the 'Server' schema + * @param {HandleThreadsArgs} handleThreads + * @param {Thread[]} handleThreads.threads + * @param {boolean | undefined} handleThreads.prepareRecordsOnly + * @returns {Promise} + */ + handleThreads = async ({threads, prepareRecordsOnly = false}: HandleThreadsArgs): Promise => { + if (!threads.length) { + throw new DataOperatorException( + 'An empty "threads" array has been passed to the handleThreads method', + ); + } + + // Get unique threads in case they are duplicated + const uniqueThreads = getUniqueRawsBy({ + raws: threads, + key: 'id', + }) as Thread[]; + + const threadsParticipants: ParticipantsPerThread[] = []; + + // Let's process the thread data + for (const thread of uniqueThreads) { + threadsParticipants.push({ + thread_id: thread.id, + participants: (thread.participants || []).map((participant) => ({ + id: participant.id, + thread_id: thread.id, + })), + }); + } + + // Get thread models to be created and updated + const preparedThreads = await this.handleRecords({ + fieldName: 'id', + findMatchingRecordBy: isRecordThreadEqualToRaw, + transformer: transformThreadRecord, + prepareRecordsOnly: true, + createOrUpdateRawValues: uniqueThreads, + tableName: THREAD, + }) as ThreadModel[]; + + // Add the models to be batched here + const batch: Model[] = [...preparedThreads]; + + // calls handler for Thread Participants + const threadParticipants = (await this.handleThreadParticipants({threadsParticipants, prepareRecordsOnly: true})) as ThreadParticipantModel[]; + batch.push(...threadParticipants); + + if (batch.length && !prepareRecordsOnly) { + await this.batchRecords(batch); + } + + return batch; + }; + + /** + * handleThreadParticipants: Handler responsible for the Create/Update operations occurring on the ThreadParticipants table from the 'Server' schema + * @param {HandleThreadParticipantsArgs} handleThreadParticipants + * @param {ParticipantsPerThread[]} handleThreadParticipants.threadsParticipants + * @param {boolean} handleThreadParticipants.prepareRecordsOnly + * @throws DataOperatorException + * @returns {Promise>} + */ + handleThreadParticipants = async ({threadsParticipants, prepareRecordsOnly}: HandleThreadParticipantsArgs): Promise => { + const batchRecords: ThreadParticipantModel[] = []; + + if (!threadsParticipants.length) { + throw new DataOperatorException( + 'An empty "thread participants" array has been passed to the handleThreadParticipants method', + ); + } + + for await (const threadParticipant of threadsParticipants) { + const {thread_id, participants} = threadParticipant; + const rawValues = getUniqueRawsBy({raws: participants, key: 'id'}) as ThreadParticipant[]; + const { + createParticipants, + deleteParticipants, + } = await sanitizeThreadParticipants({ + database: this.database, + thread_id, + rawParticipants: rawValues, + }); + + if (createParticipants?.length) { + // Prepares record for model ThreadParticipants + const participantsRecords = (await this.prepareRecords({ + createRaws: createParticipants, + transformer: transformThreadParticipantRecord, + tableName: THREAD_PARTICIPANT, + })) as ThreadParticipantModel[]; + batchRecords.push(...participantsRecords); + } + + if (deleteParticipants?.length) { + batchRecords.push(...deleteParticipants); + } + } + + if (prepareRecordsOnly) { + return batchRecords; + } + + if (batchRecords?.length) { + await this.batchRecords(batchRecords); + } + + return batchRecords; + }; +}; + +export default ThreadHandler; diff --git a/app/database/operator/server_data_operator/index.ts b/app/database/operator/server_data_operator/index.ts index 0b60d43869..87b780d1fb 100644 --- a/app/database/operator/server_data_operator/index.ts +++ b/app/database/operator/server_data_operator/index.ts @@ -9,13 +9,14 @@ import PostsInChannelHandler, {PostsInChannelHandlerMix} from '@database/operato import PostsInThreadHandler, {PostsInThreadHandlerMix} from '@database/operator/server_data_operator/handlers/posts_in_thread'; import ReactionHander, {ReactionHandlerMix} from '@database/operator/server_data_operator/handlers/reaction'; import TeamHandler, {TeamHandlerMix} from '@database/operator/server_data_operator/handlers/team'; +import ThreadHandler, {ThreadHandlerMix} from '@database/operator/server_data_operator/handlers/thread'; import UserHandler, {UserHandlerMix} from '@database/operator/server_data_operator/handlers/user'; import mix from '@utils/mix'; import type {Database} from '@nozbe/watermelondb'; interface ServerDataOperator extends ServerDataOperatorBase, PostHandlerMix, PostsInChannelHandlerMix, - PostsInThreadHandlerMix, ReactionHandlerMix, UserHandlerMix, ChannelHandlerMix, CategoryHandlerMix, TeamHandlerMix {} + PostsInThreadHandlerMix, ReactionHandlerMix, UserHandlerMix, ChannelHandlerMix, CategoryHandlerMix, TeamHandlerMix, ThreadHandlerMix {} class ServerDataOperator extends mix(ServerDataOperatorBase).with( CategoryHandler, @@ -25,6 +26,7 @@ class ServerDataOperator extends mix(ServerDataOperatorBase).with( PostsInThreadHandler, ReactionHander, TeamHandler, + ThreadHandler, UserHandler, ) { // eslint-disable-next-line no-useless-constructor diff --git a/app/database/operator/server_data_operator/transformers/thread.ts b/app/database/operator/server_data_operator/transformers/thread.ts new file mode 100644 index 0000000000..6b93bfd173 --- /dev/null +++ b/app/database/operator/server_data_operator/transformers/thread.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index'; +import {OperationType} from '@typings/database/enums'; + +import type {TransformerArgs} from '@typings/database/database'; +import type ThreadModel from '@typings/database/models/servers/thread'; +import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant'; + +const { + THREAD, + THREAD_PARTICIPANT, +} = MM_TABLES.SERVER; + +/** + * transformThreadRecord: Prepares a record of the SERVER database 'Thread' table for update or create actions. + * @param {TransformerArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformThreadRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw as Thread; + const record = value.record as ThreadModel; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + 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.replyCount = raw.reply_count; + thread.isFollowing = raw.is_following ?? record?.isFollowing; + thread.unreadReplies = raw.unread_replies; + thread.unreadMentions = raw.unread_mentions; + thread.loadedInGlobalThreads = raw.loaded_in_global_threads || record?.loadedInGlobalThreads; + }; + + return prepareBaseRecord({ + action, + database, + tableName: THREAD, + value, + fieldsMapper, + }) as Promise; +}; + +/** + * transformThreadParticipantRecord: Prepares a record of the SERVER database 'ThreadParticipant' table for update or create actions. + * @param {TransformerArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformThreadParticipantRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw as ThreadParticipant; + + // id of participant comes from server response + const fieldsMapper = (participant: ThreadParticipantModel) => { + participant.threadId = raw.thread_id; + participant.userId = raw.id; + }; + + return prepareBaseRecord({ + action, + database, + tableName: THREAD_PARTICIPANT, + value, + fieldsMapper, + }) as Promise; +}; diff --git a/app/database/operator/utils/mock.ts b/app/database/operator/utils/mock.ts index 71fd795c0a..cadd715436 100644 --- a/app/database/operator/utils/mock.ts +++ b/app/database/operator/utils/mock.ts @@ -28,7 +28,6 @@ export const mockedPosts = { pending_post_id: '', reply_count: 4, last_reply_at: 0, - participants: null, metadata: {}, }, '8fcnk3p1jt8mmkaprgajoxz115a': { @@ -55,7 +54,6 @@ export const mockedPosts = { pending_post_id: '', reply_count: 0, last_reply_at: 0, - participants: null, metadata: {}, }, '3y3w3a6gkbg73bnj3xund9o5ic': { @@ -77,7 +75,6 @@ export const mockedPosts = { pending_post_id: '', reply_count: 4, last_reply_at: 0, - participants: null, metadata: {}, }, '4btbnmticjgw7ewd3qopmpiwqw': { @@ -101,7 +98,6 @@ export const mockedPosts = { pending_post_id: '', reply_count: 0, last_reply_at: 0, - participants: null, metadata: {}, }, '4r9jmr7eqt8dxq3f9woypzurry': { @@ -124,7 +120,6 @@ export const mockedPosts = { has_reactions: true, reply_count: 7, last_reply_at: 0, - participants: null, metadata: { reactions: [ { @@ -165,7 +160,6 @@ export const mockedPosts = { pending_post_id: '', reply_count: 7, last_reply_at: 0, - participants: null, metadata: { emojis: [ { diff --git a/app/database/operator/utils/thread.ts b/app/database/operator/utils/thread.ts new file mode 100644 index 0000000000..eced042ae7 --- /dev/null +++ b/app/database/operator/utils/thread.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {Q} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; + +import type {RecordPair, SanitizeThreadParticipantsArgs} from '@typings/database/database'; +import type ThreadParticipantModel from '@typings/database/models/servers/thread_participant'; + +const {THREAD_PARTICIPANT} = MM_TABLES.SERVER; + +/** + * sanitizeThreadParticipants: Treats participants in a Thread. For example, a user can participate/not. Hence, this function + * tell us which participants to create/delete in the ThreadParticipants table. + * @param {SanitizeThreadParticipantsArgs} sanitizeThreadParticipants + * @param {Database} sanitizeThreadParticipants.database + * @param {string} sanitizeThreadParticipants.thread_id + * @param {UserProfile[]} sanitizeThreadParticipants.rawParticipants + * @returns {Promise<{createParticipants: ThreadParticipant[], deleteParticipants: ThreadParticipantModel[]}>} + */ +export const sanitizeThreadParticipants = async ({database, thread_id, rawParticipants}: SanitizeThreadParticipantsArgs) => { + const participants = (await database.collections. + get(THREAD_PARTICIPANT). + query(Q.where('thread_id', thread_id)). + fetch()) as ThreadParticipantModel[]; + + // similarObjects: Contains objects that are in both the RawParticipant array and in the ThreadParticipant table + const similarObjects: ThreadParticipantModel[] = []; + + const createParticipants: RecordPair[] = []; + + for (let i = 0; i < rawParticipants.length; i++) { + const rawParticipant = rawParticipants[i]; + + // If the participant is not present let's add them to the db + const exists = participants.find((participant) => participant.userId === rawParticipant.id); + + if (exists) { + similarObjects.push(exists); + } else { + createParticipants.push({raw: rawParticipant}); + } + } + + // finding out elements to delete using array subtract + const deleteParticipants = participants. + filter((participant) => !similarObjects.includes(participant)). + map((outCast) => outCast.prepareDestroyPermanently()); + + return {createParticipants, deleteParticipants}; +}; diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 5ec4e669b8..21af62733f 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -28,6 +28,8 @@ import { TeamSchema, TeamSearchHistorySchema, TermsOfServiceSchema, + ThreadSchema, + ThreadParticipantSchema, UserSchema, } from './table_schemas'; @@ -58,6 +60,8 @@ export const serverSchema: AppSchema = appSchema({ TeamSchema, TeamSearchHistorySchema, TermsOfServiceSchema, + ThreadSchema, + ThreadParticipantSchema, UserSchema, ], }); diff --git a/app/database/schema/server/table_schemas/index.ts b/app/database/schema/server/table_schemas/index.ts index ce131eddd4..aa115be473 100644 --- a/app/database/schema/server/table_schemas/index.ts +++ b/app/database/schema/server/table_schemas/index.ts @@ -25,4 +25,6 @@ export {default as TeamMembershipSchema} from './team_membership'; export {default as TeamSchema} from './team'; export {default as TeamSearchHistorySchema} from './team_search_history'; export {default as TermsOfServiceSchema} from './terms_of_service'; +export {default as ThreadSchema} from './thread'; +export {default as ThreadParticipantSchema} from './thread_participant'; export {default as UserSchema} from './user'; diff --git a/app/database/schema/server/table_schemas/thread.ts b/app/database/schema/server/table_schemas/thread.ts new file mode 100644 index 0000000000..b7fdcb1ac2 --- /dev/null +++ b/app/database/schema/server/table_schemas/thread.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {tableSchema} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; + +const {THREAD} = MM_TABLES.SERVER; + +export default tableSchema({ + name: THREAD, + columns: [ + {name: 'last_reply_at', type: 'number'}, + {name: 'last_viewed_at', type: 'number'}, + {name: 'is_following', type: 'boolean'}, + {name: 'reply_count', type: 'number'}, + {name: 'unread_replies', type: 'number'}, + {name: 'unread_mentions', type: 'number'}, + {name: 'loaded_in_global_threads', type: 'boolean'}, + ], +}); diff --git a/app/database/schema/server/table_schemas/thread_participant.ts b/app/database/schema/server/table_schemas/thread_participant.ts new file mode 100644 index 0000000000..5396c61af5 --- /dev/null +++ b/app/database/schema/server/table_schemas/thread_participant.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {tableSchema} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; + +const {THREAD_PARTICIPANT} = MM_TABLES.SERVER; + +export default tableSchema({ + name: THREAD_PARTICIPANT, + columns: [ + {name: 'thread_id', type: 'string', isIndexed: true}, + {name: 'user_id', type: 'string', isIndexed: true}, + ], +}); diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index 5a19719ae4..971d933a88 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -32,6 +32,8 @@ const { TEAM_MEMBERSHIP, TEAM_SEARCH_HISTORY, TERMS_OF_SERVICE, + THREAD, + THREAD_PARTICIPANT, USER, } = MM_TABLES.SERVER; @@ -459,6 +461,40 @@ describe('*** Test schema for SERVER database ***', () => { }, columnArray: [{name: 'accepted_at', type: 'number'}], }, + [THREAD]: { + name: THREAD, + unsafeSql: undefined, + columns: { + last_reply_at: {name: 'last_reply_at', type: 'number'}, + last_viewed_at: {name: 'last_viewed_at', type: 'number'}, + is_following: {name: 'is_following', type: 'boolean'}, + reply_count: {name: 'reply_count', type: 'number'}, + unread_replies: {name: 'unread_replies', type: 'number'}, + unread_mentions: {name: 'unread_mentions', type: 'number'}, + loaded_in_global_threads: {name: 'loaded_in_global_threads', type: 'boolean'}, + }, + columnArray: [ + {name: 'last_reply_at', type: 'number'}, + {name: 'last_viewed_at', type: 'number'}, + {name: 'is_following', type: 'boolean'}, + {name: 'reply_count', type: 'number'}, + {name: 'unread_replies', type: 'number'}, + {name: 'unread_mentions', type: 'number'}, + {name: 'loaded_in_global_threads', type: 'boolean'}, + ], + }, + [THREAD_PARTICIPANT]: { + name: THREAD_PARTICIPANT, + unsafeSql: undefined, + columns: { + thread_id: {name: 'thread_id', type: 'string', isIndexed: true}, + user_id: {name: 'user_id', type: 'string', isIndexed: true}, + }, + columnArray: [ + {name: 'thread_id', type: 'string', isIndexed: true}, + {name: 'user_id', type: 'string', isIndexed: true}, + ], + }, [USER]: { name: USER, unsafeSql: undefined, diff --git a/app/utils/post_list/index.ts b/app/utils/post_list/index.ts index 2a333adae8..e8574c5d86 100644 --- a/app/utils/post_list/index.ts +++ b/app/utils/post_list/index.ts @@ -326,7 +326,6 @@ export function generateCombinedPost(combinedId: string, systemPosts: PostModel[ type: Post.POST_TYPES.COMBINED_USER_ACTIVITY as PostType, user_id: '', metadata: {}, - participants: null, }; } diff --git a/types/api/config.d.ts b/types/api/config.d.ts index eae4658d83..73f8bb96c0 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -22,6 +22,7 @@ interface ClientConfig { BuildHashEnterprise: string; BuildNumber: string; CloseUnusedDirectMessages: string; + CollapsedThreads: string; CustomBrandText: string; CustomDescriptionText: string; CustomTermsOfServiceId: string; @@ -115,6 +116,7 @@ interface ClientConfig { ExperimentalViewArchivedChannels: string; ExtendSessionLengthWithActivity: string; FeatureFlagAppsEnabled?: string; + FeatureFlagCollapsedThreads?: string; GfycatApiKey: string; GfycatApiSecret: string; GoogleDeveloperKey: string; diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index 5845f3b6e1..714916bfe6 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -47,6 +47,7 @@ type Post = { update_at: number; edit_at: number; delete_at: number; + is_following?: boolean; is_pinned: boolean; user_id: string; channel_id: string; @@ -54,6 +55,7 @@ type Post = { original_id: string; message: string; type: PostType; + participants?: null | UserProfile[]; props: Record; hashtags: string; pending_post_id: string; diff --git a/types/api/threads.d.ts b/types/api/threads.d.ts new file mode 100644 index 0000000000..44faa7d05a --- /dev/null +++ b/types/api/threads.d.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type Thread = { + id: string; + reply_count: number; + last_reply_at: number; + last_viewed_at: number; + participants: UserProfile[]; + post: Post; + is_following?: boolean; + unread_replies: number; + unread_mentions: number; + loaded_in_global_threads: boolean; +}; + +type ThreadParticipant = { + id: $ID; + thread_id: $ID; +}; diff --git a/types/database/database.d.ts b/types/database/database.d.ts index d2be557a12..d8a1e13f3b 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -88,6 +88,16 @@ export type HandlePostsArgs = { prepareRecordsOnly?: boolean; }; +export type HandleThreadsArgs = { + threads: Thread[]; + prepareRecordsOnly?: boolean; +}; + +export type HandleThreadParticipantsArgs = { + prepareRecordsOnly: boolean; + threadsParticipants: ParticipantsPerThread[]; +}; + export type SanitizeReactionsArgs = { database: Database; post_id: string; @@ -95,6 +105,12 @@ export type SanitizeReactionsArgs = { skipSync?: boolean; }; +export type SanitizeThreadParticipantsArgs = { + database: Database; + thread_id: $ID; + rawParticipants: ThreadParticipant[]; +} + export type ChainPostsArgs = { order: string[]; previousPostId: string; diff --git a/types/database/models/servers/post.d.ts b/types/database/models/servers/post.d.ts index 944bfbcff1..7bf2e7563e 100644 --- a/types/database/models/servers/post.d.ts +++ b/types/database/models/servers/post.d.ts @@ -79,6 +79,9 @@ export default class PostModel extends Model { /** channel: The channel which is presenting this Post */ channel: Relation; + /** thread : the related thread for the post */ + thread: Relation; + /** hasReplies: Async function to determine if the post is part of a thread */ hasReplies: () => Promise; } diff --git a/types/database/models/servers/thread.d.ts b/types/database/models/servers/thread.d.ts new file mode 100644 index 0000000000..08154e7186 --- /dev/null +++ b/types/database/models/servers/thread.d.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Query, Relation} from '@nozbe/watermelondb'; +import Model, {Associations} from '@nozbe/watermelondb/Model'; + +/** + * The Thread model contains thread information of a post. + */ +export default class ThreadModel extends Model { + /** table (name) : Thread */ + static table: string; + + /** associations : Describes every relationship to this table. */ + static associations: Associations; + + /** lastReplyAt : The timestamp of when user last replied to the thread. */ + lastReplyAt: number; + + /** lastViewedAt : The timestamp of when user last viewed the thread. */ + lastViewedAt: number; + + /** reply_count : The total replies to the thread by all the participants. */ + replyCount: number; + + /** isFollowing: If user is following this thread or not */ + isFollowing: boolean; + + /** unread_replies : The number of replies that are not read by the user. */ + unreadReplies: number; + + /** unread_mentions : The number of mentions that are not read by the user. */ + unreadMentions: number; + + /** loaded_in_global_threads : Flag to differentiate the unread threads loaded for showing unread counts/mentions */ + loadedInGlobalThreads: boolean; + + /** participants: All the participants of the thread */ + participants: Query; + + /** post : Query returning the post data for the current thread */ + post: Relation; +} diff --git a/types/database/models/servers/thread_participant.d.ts b/types/database/models/servers/thread_participant.d.ts new file mode 100644 index 0000000000..1724b67229 --- /dev/null +++ b/types/database/models/servers/thread_participant.d.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Relation} from '@nozbe/watermelondb'; +import Model, {Associations} from '@nozbe/watermelondb/Model'; + +/** + * The Thread Participants Model is used to show the participants of a thread + */ +export default class ThreadParticipantsModel extends Model { + /** table (name) : ThreadParticipants */ + static table: string; + + /** associations : Describes every relationship to this table. */ + static associations: Associations; + + /** thread_id : The related Thread's foreign key to which this participant belongs */ + threadId: string; + + /** user_id : The user id of the user participating in the thread */ + userId: string; + + /** thread : The related record to the Thread model */ + thread: Relation; + + /** user : The related record to the User model */ + user: Relation; +} diff --git a/types/database/models/servers/user.d.ts b/types/database/models/servers/user.d.ts index 21c95180dc..392ef57c6e 100644 --- a/types/database/models/servers/user.d.ts +++ b/types/database/models/servers/user.d.ts @@ -88,6 +88,9 @@ export default class UserModel extends Model { /** teams : All the team that this user is part of */ teams: Query; + /** threadParticipations : All the thread participations this user is part of */ + threadParticipations: Query; + /** prepareStatus: Prepare the model to update the user status in a batch operation */ prepareStatus: (status: string) => void; diff --git a/types/database/raw_values.d.ts b/types/database/raw_values.d.ts index d30de0589a..5683f94a49 100644 --- a/types/database/raw_values.d.ts +++ b/types/database/raw_values.d.ts @@ -57,6 +57,11 @@ type IdValue = { value: unknown; }; +type ParticipantsPerThread = { + thread_id: string; + participants: ThreadParticipant[]; +}; + type TeamChannelHistory = { id: string; channel_ids: string[]; @@ -103,5 +108,7 @@ type RawValue = | TeamMembership | TeamSearchHistory | TermsOfService + | Thread + | ThreadParticipant | UserProfile | Pick