diff --git a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx index a25588793d..6608951dcf 100644 --- a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx +++ b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx @@ -8,12 +8,14 @@ import { AxoRadioGroup } from '../../../axo/AxoRadioGroup.dom.js'; import { DurationInSeconds } from '../../../util/durations/duration-in-seconds.std.js'; import { strictAssert } from '../../../util/assert.std.js'; import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js'; +import { isInternalFeaturesEnabled } from '../../../util/isInternalFeaturesEnabled.dom.js'; enum DurationOption { TIME_24_HOURS = 'TIME_24_HOURS', TIME_7_DAYS = 'TIME_7_DAYS', TIME_30_DAYS = 'TIME_30_DAYS', FOREVER = 'FOREVER', + DEBUG_10_SECONDS = 'DEBUG_10_SECONDS', } const DURATION_OPTIONS: Record = { @@ -21,6 +23,7 @@ const DURATION_OPTIONS: Record = { [DurationOption.TIME_7_DAYS]: DurationInSeconds.fromDays(7), [DurationOption.TIME_30_DAYS]: DurationInSeconds.fromDays(30), [DurationOption.FOREVER]: null, + [DurationOption.DEBUG_10_SECONDS]: DurationInSeconds.fromSeconds(10), }; function isValidDurationOption(value: string): value is DurationOption { @@ -152,6 +155,14 @@ export const PinMessageDialog = memo(function PinMessageDialog( {i18n('icu:PinMessageDialog__Option--FOREVER')} + {isInternalFeaturesEnabled() && ( + + + + 10 seconds (Internal) + + + )} diff --git a/ts/jobs/conversationJobQueue.preload.ts b/ts/jobs/conversationJobQueue.preload.ts index dee724de04..1c027ff9ae 100644 --- a/ts/jobs/conversationJobQueue.preload.ts +++ b/ts/jobs/conversationJobQueue.preload.ts @@ -292,6 +292,7 @@ const unpinMessageJobDataSchema = z.object({ targetAuthorAci: aciSchema, targetSentTimestamp: z.number(), unpinnedAt: z.number(), + isSyncOnly: z.boolean(), }); export type UnpinMessageJobData = z.infer; diff --git a/ts/jobs/helpers/createSendMessageJob.preload.ts b/ts/jobs/helpers/createSendMessageJob.preload.ts index 2f8e190f57..1f69aa7748 100644 --- a/ts/jobs/helpers/createSendMessageJob.preload.ts +++ b/ts/jobs/helpers/createSendMessageJob.preload.ts @@ -25,6 +25,7 @@ import { itemStorage } from '../../textsecure/Storage.preload.js'; export type SendMessageJobOptions = Readonly<{ sendName: string; // ex: 'sendExampleMessage' sendType: SendTypesType; + isSyncOnly: (data: Data) => boolean; getMessageId: (data: Data) => string | null; getMessageOptions: ( data: Data @@ -43,6 +44,7 @@ export function createSendMessageJob( const { sendName, sendType, + isSyncOnly, getMessageId, getMessageOptions, getExpirationStartTimestamp, @@ -60,7 +62,9 @@ export function createSendMessageJob( getSendRecipientLists({ log, conversation, - conversationIds: Array.from(conversation.getMemberConversationIds()), + conversationIds: isSyncOnly(data) + ? [window.ConversationController.getOurConversationIdOrThrow()] + : Array.from(conversation.getMemberConversationIds()), }); if (untrustedServiceIds.length > 0) { diff --git a/ts/jobs/helpers/sendPinMessage.preload.ts b/ts/jobs/helpers/sendPinMessage.preload.ts index 3ad7300cbd..b8845c6712 100644 --- a/ts/jobs/helpers/sendPinMessage.preload.ts +++ b/ts/jobs/helpers/sendPinMessage.preload.ts @@ -6,6 +6,9 @@ import { createSendMessageJob } from './createSendMessageJob.preload.js'; export const sendPinMessage = createSendMessageJob({ sendName: 'sendPinMessage', sendType: 'pinMessage', + isSyncOnly() { + return false; + }, getMessageId(data) { return data.targetMessageId; }, diff --git a/ts/jobs/helpers/sendUnpinMessage.preload.ts b/ts/jobs/helpers/sendUnpinMessage.preload.ts index d44a9aae82..69bf2b582e 100644 --- a/ts/jobs/helpers/sendUnpinMessage.preload.ts +++ b/ts/jobs/helpers/sendUnpinMessage.preload.ts @@ -7,6 +7,9 @@ import { createSendMessageJob } from './createSendMessageJob.preload.js'; export const sendUnpinMessage = createSendMessageJob({ sendName: 'sendUnpinMessage', sendType: 'unpinMessage', + isSyncOnly(data) { + return data.isSyncOnly; + }, getMessageId(data) { return data.targetMessageId; }, diff --git a/ts/services/expiring/pinnedMessagesCleanupService.preload.ts b/ts/services/expiring/pinnedMessagesCleanupService.preload.ts index abf40fe363..dc6baae4c5 100644 --- a/ts/services/expiring/pinnedMessagesCleanupService.preload.ts +++ b/ts/services/expiring/pinnedMessagesCleanupService.preload.ts @@ -4,6 +4,12 @@ import { DataReader, DataWriter } from '../../sql/Client.preload.js'; import { createExpiringEntityCleanupService } from './createExpiringEntityCleanupService.std.js'; import { strictAssert } from '../../util/assert.std.js'; +import { + conversationJobQueue, + conversationQueueJobEnum, +} from '../../jobs/conversationJobQueue.preload.js'; +import { getPinnedMessageTarget } from '../../util/getPinMessageTarget.preload.js'; +import { drop } from '../../util/drop.std.js'; export const pinnedMessagesCleanupService = createExpiringEntityCleanupService({ logPrefix: 'PinnedMessages', @@ -23,11 +29,39 @@ export const pinnedMessagesCleanupService = createExpiringEntityCleanupService({ }; }, cleanupExpiredEntities: async () => { - const deletedPinnedMessagesIds = + const deletedPinnedMessages = await DataWriter.deleteAllExpiredPinnedMessagesBefore(Date.now()); + const unpinnedAt = Date.now(); + const deletedPinnedMessagesIds = []; + const changedConversationIds = new Set(); + + for (const pinnedMessage of deletedPinnedMessages) { + deletedPinnedMessagesIds.push(pinnedMessage.id); + changedConversationIds.add(pinnedMessage.conversationId); + // Add to conversation queue without waiting + drop(sendUnpinSync(pinnedMessage.messageId, unpinnedAt)); + } + + for (const conversationId of changedConversationIds) { + window.reduxActions.conversations.onPinnedMessagesChanged(conversationId); + } + return deletedPinnedMessagesIds; }, subscribeToTriggers: () => { return () => null; }, }); + +async function sendUnpinSync(targetMessageId: string, unpinnedAt: number) { + const target = await getPinnedMessageTarget(targetMessageId); + if (target == null) { + return; + } + await conversationJobQueue.add({ + type: conversationQueueJobEnum.enum.UnpinMessage, + ...target, + unpinnedAt, + isSyncOnly: true, + }); +} diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index 422c5a5985..ce22cc7f07 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -1399,7 +1399,7 @@ type WritableInterface = { deletePinnedMessageByMessageId: (messageId: string) => PinnedMessageId | null; deleteAllExpiredPinnedMessagesBefore: ( beforeTimestamp: number - ) => ReadonlyArray; + ) => ReadonlyArray; removeAll: () => void; removeAllConfiguration: () => void; diff --git a/ts/sql/server/pinnedMessages.std.ts b/ts/sql/server/pinnedMessages.std.ts index 5a23bc90df..354da8dbdd 100644 --- a/ts/sql/server/pinnedMessages.std.ts +++ b/ts/sql/server/pinnedMessages.std.ts @@ -232,11 +232,11 @@ export function getNextExpiringPinnedMessageAcrossConversations( export function deleteAllExpiredPinnedMessagesBefore( db: WritableDB, beforeTimestamp: number -): ReadonlyArray { +): ReadonlyArray { const [query, params] = sql` DELETE FROM pinnedMessages WHERE expiresAt <= ${beforeTimestamp} - RETURNING id + RETURNING * `; - return db.prepare(query, { pluck: true }).all(params); + return db.prepare(query).all(params); } diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index d17dbe3b32..8a1ca37692 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -253,6 +253,7 @@ import type { StateThunk } from '../types.std.js'; import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js'; import { getPinnedMessageExpiresAt } from '../../util/pinnedMessages.std.js'; import { pinnedMessagesCleanupService } from '../../services/expiring/pinnedMessagesCleanupService.preload.js'; +import { getPinnedMessageTarget } from '../../util/getPinMessageTarget.preload.js'; const { chunk, @@ -5126,41 +5127,6 @@ function startAvatarDownload( }; } -function getMessageAuthorAci( - message: ReadonlyMessageAttributesType -): AciString { - if (isIncoming(message)) { - strictAssert( - isAciString(message.sourceServiceId), - 'Message sourceServiceId must be an ACI' - ); - return message.sourceServiceId; - } - return itemStorage.user.getCheckedAci(); -} - -type PinnedMessageTarget = ReadonlyDeep<{ - conversationId: string; - targetMessageId: string; - targetAuthorAci: AciString; - targetSentTimestamp: number; -}>; - -async function getPinnedMessageTarget( - targetMessageId: string -): Promise { - const message = await DataReader.getMessageById(targetMessageId); - if (message == null) { - throw new Error('getPinnedMessageTarget: Target message not found'); - } - return { - conversationId: message.conversationId, - targetMessageId: message.id, - targetAuthorAci: getMessageAuthorAci(message), - targetSentTimestamp: message.sent_at, - }; -} - function onPinnedMessagesChanged( conversationId: string ): StateThunk { @@ -5194,6 +5160,10 @@ function onPinnedMessageAdd( ): StateThunk { return async dispatch => { const target = await getPinnedMessageTarget(targetMessageId); + if (target == null) { + throw new Error('onPinnedMessageAdd: Missing target message'); + } + const targetConversation = window.ConversationController.get( target.conversationId ); @@ -5239,10 +5209,14 @@ function onPinnedMessageAdd( function onPinnedMessageRemove(targetMessageId: string): StateThunk { return async dispatch => { const target = await getPinnedMessageTarget(targetMessageId); + if (target == null) { + throw new Error('onPinnedMessageRemove: Missing target message'); + } await conversationJobQueue.add({ type: conversationQueueJobEnum.enum.UnpinMessage, ...target, unpinnedAt: Date.now(), + isSyncOnly: false, }); await DataWriter.deletePinnedMessageByMessageId(targetMessageId); drop(pinnedMessagesCleanupService.trigger('onPinnedMessageRemove')); diff --git a/ts/test-node/sql/server/pinnedMessages_test.node.ts b/ts/test-node/sql/server/pinnedMessages_test.node.ts index 066b80aa6f..9d5804fd07 100644 --- a/ts/test-node/sql/server/pinnedMessages_test.node.ts +++ b/ts/test-node/sql/server/pinnedMessages_test.node.ts @@ -298,7 +298,7 @@ describe('sql/server/pinnedMessages', () => { const row3 = insertPin(getParams('c1', 'c1-m3', 3, 3)); // not expired yet const result = deleteAllExpiredPinnedMessagesBefore(db, 2); assertRows([row3]); - assert.deepEqual(result, [row1.id, row2.id]); + assert.deepEqual(result, [row1, row2]); }); }); }); diff --git a/ts/util/getPinMessageTarget.preload.ts b/ts/util/getPinMessageTarget.preload.ts new file mode 100644 index 0000000000..d8a3eb6e35 --- /dev/null +++ b/ts/util/getPinMessageTarget.preload.ts @@ -0,0 +1,45 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isIncoming } from '../messages/helpers.std.js'; +import type { ReadonlyMessageAttributesType } from '../model-types.js'; +import { DataReader } from '../sql/Client.preload.js'; +import { itemStorage } from '../textsecure/Storage.preload.js'; +import type { AciString } from '../types/ServiceId.std.js'; +import { strictAssert } from './assert.std.js'; +import { isAciString } from './isAciString.std.js'; + +export type PinnedMessageTarget = Readonly<{ + conversationId: string; + targetMessageId: string; + targetAuthorAci: AciString; + targetSentTimestamp: number; +}>; + +function getMessageAuthorAci( + message: ReadonlyMessageAttributesType +): AciString { + if (isIncoming(message)) { + strictAssert( + isAciString(message.sourceServiceId), + 'Message sourceServiceId must be an ACI' + ); + return message.sourceServiceId; + } + return itemStorage.user.getCheckedAci(); +} + +export async function getPinnedMessageTarget( + targetMessageId: string +): Promise { + const message = await DataReader.getMessageById(targetMessageId); + if (message == null) { + return null; + } + return { + conversationId: message.conversationId, + targetMessageId: message.id, + targetAuthorAci: getMessageAuthorAci(message), + targetSentTimestamp: message.sent_at, + }; +}