// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Database, Model, Q, Query} from '@nozbe/watermelondb'; import {of as of$, combineLatestWith, combineLatest} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {MM_TABLES} from '@constants/database'; import {SKU_SHORT_NAME} from '@constants/license'; import {logDebug, logWarning} from '@utils/log'; import {updatePermalinkMetadata} from '@utils/permalink_sync'; import {queryGroupsByNames} from './group'; import {querySavedPostsPreferences} from './preference'; import {getConfigValue, observeConfigBooleanValue, observeConfigIntValue, observeIsMinimumLicenseTier} from './system'; import {queryUsersByUsername, observeUser, observeCurrentUser} from './user'; import type PostModel from '@typings/database/models/servers/post'; import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel'; import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES; const DEFAULT_BURN_ON_READ_DURATION_SECONDS = '600'; const DEFAULT_BURN_ON_READ_MAXIMUM_TTL_SECONDS = '604800'; export const prepareDeletePost = async (post: PostModel): Promise => { const preparedModels: Model[] = [post.prepareDestroyPermanently()]; const relations: Array> = [post.drafts, post.files, post.reactions]; for await (const models of relations) { try { models.forEach((m) => { preparedModels.push(m.prepareDestroyPermanently()); }); } catch { // Record not found, do nothing } } // If the post is a root post, delete the postsInThread model if (!post.rootId) { try { const postsInThread = await post.postsInThread.fetch(); if (postsInThread) { postsInThread.forEach((m) => { preparedModels.push(m.prepareDestroyPermanently()); }); } } catch { // Record not found, do nothing } } // If thread exists, delete thread, participants and threadsInTeam try { const thread = await post.thread.fetch(); if (thread) { const participants = await thread.participants.fetch(); if (participants.length) { preparedModels.push(...participants.map((p) => p.prepareDestroyPermanently())); } const threadsInTeam = await thread.threadsInTeam.fetch(); if (threadsInTeam.length) { preparedModels.push(...threadsInTeam.map((t) => t.prepareDestroyPermanently())); } preparedModels.push(thread.prepareDestroyPermanently()); } } catch { // Thread not found, do nothing } return preparedModels; }; export const getPostById = async (database: Database, postId: string) => { try { const postModel = await database.get(POST).find(postId); return postModel; } catch { return undefined; } }; export const observePost = (database: Database, postId: string) => { return database.get(POST).query(Q.where('id', postId), Q.take(1)).observe().pipe( switchMap((result) => (result.length ? result[0].observe() : of$(undefined))), ); }; export const observePostAuthor = (database: Database, post: PostModel) => { return observeUser(database, post.userId); }; export const observePostSaved = (database: Database, postId: string) => { return querySavedPostsPreferences(database, postId). observeWithColumns(['value']).pipe( switchMap( (pref) => of$(Boolean(pref[0]?.value === 'true')), ), ); }; export const queryPostsInChannel = (database: Database, channelId: string) => { return database.get(POSTS_IN_CHANNEL).query( Q.where('channel_id', channelId), Q.sortBy('latest', Q.desc), ); }; export const queryPostsInThread = (database: Database, rootId: string, sorted = false, includeDeleted = false) => { const clauses: Q.Clause[] = [Q.where('root_id', rootId)]; if (!includeDeleted) { clauses.unshift(Q.experimentalJoinTables([POST])); clauses.push(Q.on(POST, 'delete_at', Q.eq(0))); } if (sorted) { clauses.push(Q.sortBy('latest', Q.desc)); } return database.get(POSTS_IN_THREAD).query(...clauses); }; export const queryPostReplies = (database: Database, rootId: string, excludeDeleted = true) => { const clauses: Q.Clause[] = [Q.where('root_id', rootId)]; if (excludeDeleted) { clauses.push(Q.where('delete_at', Q.eq(0))); } return database.get(POST).query(...clauses); }; export const getRecentPostsInThread = async (database: Database, rootId: string) => { const chunks = await queryPostsInThread(database, rootId, true, true).fetch(); if (chunks.length) { const recent = chunks[0]; const post = await getPostById(database, rootId); if (post) { return queryPostsChunk(database, post.channelId, recent.earliest, recent.latest).fetch(); } } return []; }; export const getLastPostInThread = async (database: Database, rootId: string) => { const chunks = await queryPostsInThread(database, rootId, true, true).fetch(); if (chunks.length) { const recent = chunks[0]; const post = await getPostById(database, rootId); if (post) { const posts = await queryPostsChunk(database, rootId, recent.earliest, recent.latest, true, true, 1).fetch(); return posts[0]; } } return undefined; }; export const queryPostsChunk = (database: Database, id: string, earliest: number, latest: number, inThread = false, includeDeleted = false, limit = 0) => { const conditions: Q.Where[] = [Q.where('create_at', Q.between(earliest, latest))]; if (inThread) { conditions.push(Q.where('root_id', id)); } else { conditions.push(Q.where('channel_id', id)); } if (!includeDeleted) { conditions.push(Q.where('delete_at', Q.eq(0))); } const clauses: Q.Clause[] = [ Q.and( ...conditions, ), Q.sortBy('create_at', Q.desc), ]; if (limit) { clauses.push(Q.take(limit)); } return database.get(POST).query(...clauses); }; export const getRecentPostsInChannel = async (database: Database, channelId: string, includeDeleted = false) => { const chunks = await queryPostsInChannel(database, channelId).fetch(); if (chunks.length) { const recent = chunks[0]; return queryPostsChunk(database, channelId, recent.earliest, recent.latest, false, includeDeleted).fetch(); } return []; }; export const queryPostsById = (database: Database, postIds: string[], sort?: Q.SortOrder) => { const clauses: Q.Clause[] = [Q.where('id', Q.oneOf(postIds))]; if (sort) { clauses.push(Q.sortBy('create_at', sort)); } return database.get(POST).query(...clauses); }; export const queryPostsByType = (database: Database, type: string) => { const clauses: Q.Clause[] = [Q.where('type', type)]; return database.get(POST).query(...clauses); }; export const queryPostsBetween = (database: Database, earliest: number, latest: number, sort: Q.SortOrder | null, userId?: string, channelId?: string, rootId?: string) => { const andClauses = [Q.where('create_at', Q.between(earliest, latest))]; if (channelId) { andClauses.push(Q.where('channel_id', channelId)); } if (userId) { andClauses.push(Q.where('user_id', userId)); } if (rootId != null) { andClauses.push(Q.where('root_id', rootId)); } const clauses: Q.Clause[] = [Q.and(...andClauses)]; if (sort != null) { clauses.push(Q.sortBy('create_at', sort)); } return database.get(POST).query(...clauses); }; export const queryPinnedPostsInChannel = (database: Database, channelId: string) => { return database.get(POST).query( Q.and( Q.where('channel_id', channelId), Q.where('is_pinned', Q.eq(true)), ), Q.sortBy('create_at', Q.asc), ); }; export const observePinnedPostsInChannel = (database: Database, channelId: string) => { return queryPinnedPostsInChannel(database, channelId).observe(); }; export const observeSavedPostsByIds = (database: Database, postIds: string[]) => { return querySavedPostsPreferences(database).extend( Q.where('name', Q.oneOf(postIds)), ).observeWithColumns(['name']).pipe( switchMap((prefs) => of$(new Set(prefs.map((p) => p.name)))), ); }; export const getIsPostPriorityEnabled = async (database: Database) => { const cfg = await getConfigValue(database, 'PostPriority'); return cfg === 'true'; }; export const getIsPostAcknowledgementsEnabled = async (database: Database) => { const cfg = await getConfigValue(database, 'PostAcknowledgements'); return cfg === 'true'; }; export const observeIsPostPriorityEnabled = (database: Database) => { return observeConfigBooleanValue(database, 'PostPriority'); }; export const observeIsBoREnabled = (database: Database) => { const featureEnabled = observeConfigBooleanValue(database, 'EnableBurnOnRead'); const licenseValid = observeIsMinimumLicenseTier(database, SKU_SHORT_NAME.EnterpriseAdvanced); return combineLatest([featureEnabled, licenseValid]).pipe( switchMap(([enabled, licensed]) => of$(enabled && licensed)), ); }; export const observeBoRConfig = (database: Database) => { const borDurationSecondsObservable = observeConfigIntValue(database, 'BurnOnReadDurationSeconds'); const maxBoRDurationSecondsStringObservable = observeConfigIntValue(database, 'BurnOnReadMaximumTimeToLiveSeconds'); // merge all observables and return single observable of object with both values return combineLatest([borDurationSecondsObservable, maxBoRDurationSecondsStringObservable]).pipe( switchMap(([borDurationSeconds, borMaximumTimeToLiveSeconds]) => { const borConfig = { enabled: false, borDurationSeconds: borDurationSeconds || parseInt(DEFAULT_BURN_ON_READ_DURATION_SECONDS, 10), borMaximumTimeToLiveSeconds: borMaximumTimeToLiveSeconds || parseInt(DEFAULT_BURN_ON_READ_MAXIMUM_TTL_SECONDS, 10), }; return of$(borConfig); }), ); }; export const observeIsPostAcknowledgementsEnabled = (database: Database) => { return observeConfigBooleanValue(database, 'PostAcknowledgements'); }; export const observePersistentNotificationsEnabled = (database: Database) => { const user = observeCurrentUser(database); const enabledForAll = observeConfigBooleanValue(database, 'AllowPersistentNotifications'); const enabledForGuests = observeConfigBooleanValue(database, 'AllowPersistentNotificationsForGuests'); return user.pipe( combineLatestWith(enabledForAll, enabledForGuests), switchMap(([u, forAll, forGuests]) => { if (u?.isGuest) { return of$(forAll && forGuests); } return of$(forAll); }), distinctUntilChanged(), ); }; export const countUsersFromMentions = async (database: Database, mentions: string[]) => { const groupsQuery = queryGroupsByNames(database, mentions).fetch(); const usersQuery = queryUsersByUsername(database, mentions).fetchCount(); const [groups, usersCount] = await Promise.all([groupsQuery, usersQuery]); return groups.reduce((acc, v) => acc + v.memberCount, usersCount); }; export const queryPostsWithPermalinkReferences = async ( database: Database, referencedPostId: string, ): Promise => { try { const clauses: Q.Clause[] = [ Q.where('metadata', Q.notEq(null)), Q.where('delete_at', Q.eq(0)), ]; const postsWithMetadata = await database.get(POST). query(...clauses). fetch(); const referencingPosts: PostModel[] = []; for (const post of postsWithMetadata) { const metadata = post.metadata; if (metadata?.embeds?.length) { for (const embed of metadata.embeds) { if (embed.type === 'permalink' && embed.data?.post_id === referencedPostId) { referencingPosts.push(post); break; } } } } return referencingPosts; } catch (error) { return []; } }; /** * Find posts that contain permalink previews referencing the given post ID */ export const findPostsWithPermalinkReferences = async ( database: Database, referencedPostId: string, ): Promise => { try { const referencingPosts = await queryPostsWithPermalinkReferences(database, referencedPostId); return referencingPosts; } catch (error) { logWarning('Error finding posts with permalink references:', error); return []; } }; /** * Synchronize permalink previews when a post is edited */ export const syncPermalinkPreviewsForEditedPost = async ( database: Database, editedPost: Post, ): Promise => { try { const referencingPosts = await findPostsWithPermalinkReferences( database, editedPost.id, ); if (!referencingPosts.length) { return []; } const updatedPosts: PostModel[] = []; for (const referencingPost of referencingPosts) { const updatedPost = updatePermalinkMetadata( referencingPost, editedPost.id, editedPost, ); if (updatedPost) { updatedPosts.push(updatedPost); } } logDebug(`Updated ${updatedPosts.length} permalink previews for edited post ${editedPost.id}`); return updatedPosts; } catch (error) { logWarning('Error syncing permalink previews for edited post:', error); return []; } };