diff --git a/ts/jobs/initializeAllJobQueues.preload.ts b/ts/jobs/initializeAllJobQueues.preload.ts index 8587f1c16a..709d5e33d9 100644 --- a/ts/jobs/initializeAllJobQueues.preload.ts +++ b/ts/jobs/initializeAllJobQueues.preload.ts @@ -5,6 +5,7 @@ import type { reportMessage, isOnline } from '../textsecure/WebAPI.preload.js'; import { drop } from '../util/drop.std.js'; import { CallLinkFinalizeDeleteManager } from './CallLinkFinalizeDeleteManager.preload.js'; import { chatFolderCleanupService } from '../services/expiring/chatFolderCleanupService.preload.js'; +import { pinnedMessagesCleanupService } from '../services/expiring/pinnedMessagesCleanupService.preload.js'; import { callLinkRefreshJobQueue } from './callLinkRefreshJobQueue.preload.js'; import { conversationJobQueue } from './conversationJobQueue.preload.js'; import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue.preload.js'; @@ -52,6 +53,7 @@ export function initializeAllJobQueues({ drop(callLinkRefreshJobQueue.streamJobs()); drop(CallLinkFinalizeDeleteManager.start()); drop(chatFolderCleanupService.start('initializeAllJobQueues')); + drop(pinnedMessagesCleanupService.start('initializeAllJobQueues')); } export async function shutdownAllJobQueues(): Promise { @@ -67,5 +69,6 @@ export async function shutdownAllJobQueues(): Promise { reportSpamJobQueue.shutdown(), CallLinkFinalizeDeleteManager.stop(), chatFolderCleanupService.stop('shutdownAllJobQueues'), + pinnedMessagesCleanupService.stop('shutdownAllJobQueues'), ]); } diff --git a/ts/messageModifiers/PinnedMessages.preload.ts b/ts/messageModifiers/PinnedMessages.preload.ts index 6ddd9f71ab..497815b4c0 100644 --- a/ts/messageModifiers/PinnedMessages.preload.ts +++ b/ts/messageModifiers/PinnedMessages.preload.ts @@ -12,6 +12,8 @@ import { SignalService as Proto } from '../protobuf/index.std.js'; import type { ConversationModel } from '../models/conversations.preload.js'; import { getPinnedMessagesLimit } from '../util/pinnedMessages.dom.js'; import { getPinnedMessageExpiresAt } from '../util/pinnedMessages.std.js'; +import { pinnedMessagesCleanupService } from '../services/expiring/pinnedMessagesCleanupService.preload.js'; +import { drop } from '../util/drop.std.js'; const { AccessRequired } = Proto.AccessControl; const { Role } = Proto.Member; @@ -93,6 +95,8 @@ export async function onPinnedMessageAdd( } } + drop(pinnedMessagesCleanupService.trigger('onPinnedMessageAdd')); + if (result.change?.inserted) { await targetConversation.addNotification('pinned-message-notification', { pinnedMessageId: targetMessage.id, @@ -144,6 +148,7 @@ export async function onPinnedMessageRemove( log.info( `Deleted pinned message ${deletedPinnedMessageId} for messageId ${targetMessageId}` ); + drop(pinnedMessagesCleanupService.trigger('onPinnedMessageRemove')); window.reduxActions.pinnedMessages.onPinnedMessagesChanged( targetConversationId diff --git a/ts/services/expiring/createExpiringEntityCleanupService.std.ts b/ts/services/expiring/createExpiringEntityCleanupService.std.ts index 179891edee..c58b03df3f 100644 --- a/ts/services/expiring/createExpiringEntityCleanupService.std.ts +++ b/ts/services/expiring/createExpiringEntityCleanupService.std.ts @@ -10,8 +10,10 @@ import { longTimeoutAsync } from '../../util/timeout.std.js'; const parentLog = createLogger('ExpiringEntityCleanupService'); +type EntityId = string | number; + export type ExpiringEntity = Readonly<{ - id: string; + id: EntityId; expiresAtMs: number; }>; @@ -21,7 +23,7 @@ export type Unsubscribe = () => void; export type ExpiringEntityCleanupServiceOptions = Readonly<{ logPrefix: string; getNextExpiringEntity: () => Promise; - cleanupExpiredEntities: () => Promise>; + cleanupExpiredEntities: () => Promise>; subscribeToTriggers: (trigger: Trigger) => Unsubscribe; _mockGetCurrentTime?: () => number; _mockScheduleLongTimeout?: (ms: number, signal: AbortSignal) => Promise; diff --git a/ts/services/expiring/pinnedMessagesCleanupService.preload.ts b/ts/services/expiring/pinnedMessagesCleanupService.preload.ts new file mode 100644 index 0000000000..abf40fe363 --- /dev/null +++ b/ts/services/expiring/pinnedMessagesCleanupService.preload.ts @@ -0,0 +1,33 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { DataReader, DataWriter } from '../../sql/Client.preload.js'; +import { createExpiringEntityCleanupService } from './createExpiringEntityCleanupService.std.js'; +import { strictAssert } from '../../util/assert.std.js'; + +export const pinnedMessagesCleanupService = createExpiringEntityCleanupService({ + logPrefix: 'PinnedMessages', + getNextExpiringEntity: async () => { + const nextExpiringPinnedMessage = + await DataReader.getNextExpiringPinnedMessageAcrossConversations(); + if (nextExpiringPinnedMessage == null) { + return null; + } + strictAssert( + nextExpiringPinnedMessage.expiresAt != null, + 'nextExpiringPinnedMessage.expiresAt is null' + ); + return { + id: nextExpiringPinnedMessage.id, + expiresAtMs: nextExpiringPinnedMessage.expiresAt, + }; + }, + cleanupExpiredEntities: async () => { + const deletedPinnedMessagesIds = + await DataWriter.deleteAllExpiredPinnedMessagesBefore(Date.now()); + return deletedPinnedMessagesIds; + }, + subscribeToTriggers: () => { + return () => null; + }, +}); diff --git a/ts/state/ducks/pinnedMessages.preload.ts b/ts/state/ducks/pinnedMessages.preload.ts index b57923caaa..ee5221c3fa 100644 --- a/ts/state/ducks/pinnedMessages.preload.ts +++ b/ts/state/ducks/pinnedMessages.preload.ts @@ -36,6 +36,8 @@ import { MESSAGE_CHANGED, TARGETED_CONVERSATION_CHANGED, } from './conversations.preload.js'; +import { pinnedMessagesCleanupService } from '../../services/expiring/pinnedMessagesCleanupService.preload.js'; +import { drop } from '../../util/drop.std.js'; type PreloadData = ReadonlyDeep<{ conversationId: string; @@ -156,6 +158,7 @@ function onPinnedMessageAdd( expiresAt, pinnedAt, }); + drop(pinnedMessagesCleanupService.trigger('onPinnedMessageAdd')); await targetConversation.addNotification('pinned-message-notification', { pinnedMessageId: targetMessageId, @@ -174,7 +177,7 @@ function onPinnedMessageRemove(targetMessageId: string): StateThunk { ...target, }); await DataWriter.deletePinnedMessageByMessageId(targetMessageId); - + drop(pinnedMessagesCleanupService.trigger('onPinnedMessageRemove')); dispatch(onPinnedMessagesChanged(target.conversationId)); }; } diff --git a/ts/test-node/services/expiring/createExpiringEntityCleanupService_test.node.ts b/ts/test-node/services/expiring/createExpiringEntityCleanupService_test.node.ts index 3514791941..dd58a0ee0c 100644 --- a/ts/test-node/services/expiring/createExpiringEntityCleanupService_test.node.ts +++ b/ts/test-node/services/expiring/createExpiringEntityCleanupService_test.node.ts @@ -109,7 +109,7 @@ describe('createExpiringEntityCleanupService', () => { }, async cleanupExpiredEntities() { calls.push('cleanupExpiredEntities'); - const deletedIds: Array = []; + const deletedIds: Array = []; const undeleted: Array = []; for (const entity of mockExpiringEntities) { if (entity.expiresAtMs <= clock.getCurrentTime()) {