diff --git a/ts/background.preload.ts b/ts/background.preload.ts index fcb05ee94d..9cd57c43a2 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -2530,7 +2530,7 @@ export async function startApp(): Promise { confirm(); return; } - const { pollTerminate, timestamp } = data.message; + const { pollTerminate, timestamp, expireTimer } = data.message; const parsedTerm = safeParsePartial(PollTerminateSchema, pollTerminate); if (!parsedTerm.success) { @@ -2562,6 +2562,8 @@ export async function startApp(): Promise { targetTimestamp: parsedTerm.data.targetTimestamp, receivedAtDate: data.receivedAtDate, timestamp, + expireTimer, + expirationStartTimestamp: undefined, }; drop(Polls.onPollTerminate(attributes)); @@ -3026,7 +3028,7 @@ export async function startApp(): Promise { confirm(); return; } - const { pollTerminate, timestamp } = data.message; + const { pollTerminate, timestamp, expireTimer } = data.message; const parsedTerm = safeParsePartial(PollTerminateSchema, pollTerminate); if (!parsedTerm.success) { @@ -3052,6 +3054,8 @@ export async function startApp(): Promise { source: Polls.PollSource.FromSync, targetTimestamp: parsedTerm.data.targetTimestamp, receivedAtDate: data.receivedAtDate, + expireTimer, + expirationStartTimestamp: data.expirationStartTimestamp, timestamp, }; diff --git a/ts/messageModifiers/Polls.preload.ts b/ts/messageModifiers/Polls.preload.ts index 8b57082744..267a86af37 100644 --- a/ts/messageModifiers/Polls.preload.ts +++ b/ts/messageModifiers/Polls.preload.ts @@ -23,6 +23,7 @@ import { strictAssert } from '../util/assert.std.js'; import { getMessageIdForLogging } from '../util/idForLogging.preload.js'; import { drop } from '../util/drop.std.js'; import { maybeNotify } from '../messages/maybeNotify.preload.js'; +import type { DurationInSeconds } from '../util/durations/duration-in-seconds.std.js'; const log = createLogger('Polls'); @@ -53,6 +54,8 @@ export type PollTerminateAttributesType = { targetTimestamp: number; timestamp: number; receivedAtDate: number; + expireTimer: DurationInSeconds | undefined; + expirationStartTimestamp: number | undefined; }; const pollVoteCache = new Map(); @@ -578,6 +581,8 @@ export async function handlePollTerminate( terminatorId: terminate.fromConversationId, timestamp: terminate.timestamp, isMeTerminating: isMe(author.attributes), + expireTimer: terminate.expireTimer, + expirationStartTimestamp: terminate.expirationStartTimestamp, }); window.reduxActions.conversations.markOpenConversationRead(conversation.id); diff --git a/ts/messageModifiers/ReadSyncs.preload.ts b/ts/messageModifiers/ReadSyncs.preload.ts index d4e71b5b92..87d956ceb9 100644 --- a/ts/messageModifiers/ReadSyncs.preload.ts +++ b/ts/messageModifiers/ReadSyncs.preload.ts @@ -10,7 +10,10 @@ import { StartupQueue } from '../util/StartupQueue.std.js'; import { drop } from '../util/drop.std.js'; import { getMessageIdForLogging } from '../util/idForLogging.preload.js'; import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp.std.js'; -import { isIncoming } from '../state/selectors/message.preload.js'; +import { + isIncoming, + isPollTerminate, +} from '../state/selectors/message.preload.js'; import { isMessageUnread } from '../util/isMessageUnread.std.js'; import { notificationService } from '../services/notifications.preload.js'; import { queueUpdateMessage } from '../util/messageBatcher.preload.js'; @@ -175,8 +178,10 @@ export async function onSync(sync: ReadSyncAttributesType): Promise { serviceId: item.sourceServiceId, reason: logId, }); - - return isIncoming(item) && sender?.id === readSync.senderId; + return ( + (isIncoming(item) || isPollTerminate(item)) && + sender?.id === readSync.senderId + ); }); if (!found) { diff --git a/ts/messages/maybeNotify.preload.ts b/ts/messages/maybeNotify.preload.ts index ca089c9973..99111bef2c 100644 --- a/ts/messages/maybeNotify.preload.ts +++ b/ts/messages/maybeNotify.preload.ts @@ -12,7 +12,7 @@ import { shouldNotify as shouldNotifyDuringNotificationProfile } from '../types/ import { NotificationType } from '../types/notifications.std.js'; import { isMessageUnread } from '../util/isMessageUnread.std.js'; import { isDirectConversation } from '../util/whatTypeOfConversation.dom.js'; -import { hasExpiration } from '../types/Message2.preload.js'; +import { isExpiringMessage } from '../types/Message2.preload.js'; import { notificationService } from '../services/notifications.preload.js'; import { getNotificationTextForMessage } from '../util/getNotificationTextForMessage.preload.js'; import type { MessageAttributesType } from '../model-types.d.ts'; @@ -128,7 +128,6 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise { const { url, absolutePath } = await conversation.getAvatarOrIdenticon(); const messageId = messageForNotification.id; - const isExpiringMessage = hasExpiration(messageForNotification); notificationService.add({ senderTitle, @@ -138,7 +137,7 @@ export async function maybeNotify(args: MaybeNotifyArgs): Promise { : messageForNotification.storyId, notificationIconUrl: url, notificationIconAbsolutePath: absolutePath, - isExpiringMessage, + isExpiringMessage: isExpiringMessage(messageForNotification), message: getNotificationTextForMessage(messageForNotification), messageId, reaction: reaction diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index 3c5dd4c135..4ca1f1461f 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -3501,6 +3501,8 @@ export class ConversationModel { terminatorId: string; timestamp: number; isMeTerminating: boolean; + expireTimer: DurationInSeconds | undefined; + expirationStartTimestamp: number | undefined; }): Promise { const terminatorConversation = window.ConversationController.get( params.terminatorId @@ -3522,6 +3524,8 @@ export class ConversationModel { readStatus: params.isMeTerminating ? ReadStatus.Read : ReadStatus.Unread, seenStatus: params.isMeTerminating ? SeenStatus.Seen : SeenStatus.Unseen, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, + expireTimer: params.expireTimer, + expirationStartTimestamp: params.expirationStartTimestamp, }); await window.MessageCache.saveMessage(message, { forceSave: true }); diff --git a/ts/polls/enqueuePollTerminateForSend.preload.ts b/ts/polls/enqueuePollTerminateForSend.preload.ts index 65b150f3c5..caeff82855 100644 --- a/ts/polls/enqueuePollTerminateForSend.preload.ts +++ b/ts/polls/enqueuePollTerminateForSend.preload.ts @@ -50,6 +50,8 @@ export async function enqueuePollTerminateForSend({ targetTimestamp, receivedAtDate: timestamp, timestamp, + expireTimer: conversation.get('expireTimer'), + expirationStartTimestamp: Date.now(), }; await handlePollTerminate(message, terminate, { shouldPersist: true }); diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 864e077f9f..0da8238b62 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -3349,28 +3349,40 @@ function getUnreadByConversationAndMarkRead( return db.transaction(() => { const expirationStartTimestamp = Math.min(now, readAt ?? Infinity); - const expirationJsonPatch = JSON.stringify({ expirationStartTimestamp }); - - const [updateExpirationQuery, updateExpirationParams] = sql` + const updateExpirationFragment = sqlFragment` UPDATE messages - INDEXED BY expiring_message_by_conversation_and_received_at SET - expirationStartTimestamp = ${expirationStartTimestamp}, - json = json_patch(json, ${expirationJsonPatch}) + expirationStartTimestamp = ${expirationStartTimestamp} WHERE conversationId = ${conversationId} AND (${_storyIdPredicate(storyId, includeStoryReplies)}) AND - isStory IS 0 AND - type IS 'incoming' AND - ( - expirationStartTimestamp IS NULL OR - expirationStartTimestamp > ${expirationStartTimestamp} - ) AND - expireTimer > 0 AND - received_at <= ${readMessageReceivedAt}; + type IN ('incoming', 'poll-terminate') AND + hasExpireTimer IS 1 AND + received_at <= ${readMessageReceivedAt} `; - db.prepare(updateExpirationQuery).run(updateExpirationParams); + // 1. Update expirationStartTimestamps for messages without an + // expirationStartTimestamp + const [updateNullEpirationStartQuery, updateNullExpirationStartParams] = + sql` + ${updateExpirationFragment} AND + expirationStartTimestamp IS NULL; + `; + db.prepare(updateNullEpirationStartQuery).run( + updateNullExpirationStartParams + ); + + // 2. Update expirationStartTimestamps for messages with a later + // expirationStartTimestamp. These are run in two separate queries to allow + // each to use the index on expirationStartTimestamp + const [updateLateExpirationStartQuery, updateLateExpirationStartParams] = + sql` + ${updateExpirationFragment} AND + expirationStartTimestamp > ${expirationStartTimestamp}; + `; + db.prepare(updateLateExpirationStartQuery).run( + updateLateExpirationStartParams + ); const [selectQuery, selectParams] = sql` SELECT @@ -5497,7 +5509,8 @@ function getMessagesUnexpectedlyMissingExpirationStartTimestamp( readStatus = ${ReadStatus.Read} OR readStatus = ${ReadStatus.Viewed} OR readStatus IS NULL - )) + )) OR + (type IS 'poll-terminate') ); ` ) diff --git a/ts/sql/migrations/1530-update-expiring-index.std.ts b/ts/sql/migrations/1530-update-expiring-index.std.ts new file mode 100644 index 0000000000..e2e7c9aa1e --- /dev/null +++ b/ts/sql/migrations/1530-update-expiring-index.std.ts @@ -0,0 +1,20 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from '@signalapp/sqlcipher'; + +export default function updateToSchemaVersion1520(db: Database): void { + db.exec( + 'DROP INDEX IF EXISTS expiring_message_by_conversation_and_received_at;' + ); + + db.exec(` + ALTER TABLE messages ADD COLUMN hasExpireTimer INTEGER NOT NULL + GENERATED ALWAYS AS (COALESCE(expireTimer, 0) > 0) VIRTUAL; + `); + + db.exec(` + CREATE INDEX messages_conversationId_hasExpireTimer_expirationStartTimestamp + ON messages (conversationId, hasExpireTimer, expirationStartTimestamp); + `); +} diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts index 90477eae05..c1c74b3804 100644 --- a/ts/sql/migrations/index.node.ts +++ b/ts/sql/migrations/index.node.ts @@ -128,6 +128,7 @@ import updateToSchemaVersion1490 from './1490-lowercase-notification-profiles.st import updateToSchemaVersion1500 from './1500-search-polls.std.js'; import updateToSchemaVersion1510 from './1510-chat-folders-normalize-all-chats.std.js'; import updateToSchemaVersion1520 from './1520-poll-votes-unread.std.js'; +import updateToSchemaVersion1530 from './1530-update-expiring-index.std.js'; import { DataWriter } from '../Server.node.js'; @@ -1614,6 +1615,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1500, update: updateToSchemaVersion1500 }, { version: 1510, update: updateToSchemaVersion1510 }, { version: 1520, update: updateToSchemaVersion1520 }, + { version: 1530, update: updateToSchemaVersion1530 }, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/test-electron/sql/markRead_test.preload.ts b/ts/test-electron/sql/markRead_test.preload.ts index f3dc4b5212..726a408323 100644 --- a/ts/test-electron/sql/markRead_test.preload.ts +++ b/ts/test-electron/sql/markRead_test.preload.ts @@ -330,7 +330,7 @@ describe('sql/markRead', () => { const now = Date.now(); assert.lengthOf(await _getAllMessages(), 0); - const start = Date.now(); + const start = now; const readAt = start + 20; const conversationId = generateUuid(); const expireTimer = DurationInSeconds.fromSeconds(15); @@ -345,7 +345,7 @@ describe('sql/markRead', () => { received_at: start + 1, timestamp: start + 1, expireTimer, - expirationStartTimestamp: start + 1, + expirationStartTimestamp: start + 100, readStatus: ReadStatus.Read, }; const message2: MessageAttributesType = { @@ -436,16 +436,23 @@ describe('sql/markRead', () => { (left, right) => left.timestamp - right.timestamp ); + assert.strictEqual(sorted[0].id, message1.id, 'checking message 1'); + assert.strictEqual( + sorted[0].expirationStartTimestamp, + now, + "message1's expirationStartTimestamp was moved earlier" + ); + assert.strictEqual(sorted[1].id, message2.id, 'checking message 2'); - assert.isAtMost( - sorted[1].expirationStartTimestamp ?? Infinity, + assert.strictEqual( + sorted[1].expirationStartTimestamp, now, 'checking message 2 expirationStartTimestamp' ); assert.strictEqual(sorted[3].id, message4.id, 'checking message 4'); - assert.isAtMost( - sorted[3].expirationStartTimestamp ?? Infinity, + assert.strictEqual( + sorted[3].expirationStartTimestamp, now, 'checking message 4 expirationStartTimestamp' ); diff --git a/ts/test-node/sql/migration_1530_test.node.ts b/ts/test-node/sql/migration_1530_test.node.ts new file mode 100644 index 0000000000..684d9edb5a --- /dev/null +++ b/ts/test-node/sql/migration_1530_test.node.ts @@ -0,0 +1,93 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { assert } from 'chai'; + +import { type WritableDB } from '../../sql/Interface.std.js'; +import { sql, sqlFragment } from '../../sql/util.std.js'; +import { createDB, explain, updateToVersion } from './helpers.node.js'; + +describe('SQL/updateToSchemaVersion1530', () => { + let db: WritableDB; + + beforeEach(() => { + db = createDB(); + updateToVersion(db, 1530); + }); + afterEach(() => { + db.close(); + }); + + const CORE_UPDATE_QUERY = sqlFragment` + UPDATE messages + SET + expirationStartTimestamp = 342342 + WHERE + conversationId = 'conversationId' AND + type IN ('incoming', 'poll-terminate') AND + hasExpireTimer IS 1 AND + received_at < 1304923 + `; + + const UPDATE_WHEN_NULL_START_QUERY = sqlFragment` + ${CORE_UPDATE_QUERY} AND + expirationStartTimestamp IS NULL + `; + const UPDATE_WHEN_LATE_START_QUERY = sqlFragment` + ${CORE_UPDATE_QUERY} AND + expirationStartTimestamp > 342342 + `; + + it('uses index efficiently with null start + storyId condition', () => { + const detail = explain( + db, + sql` + ${UPDATE_WHEN_NULL_START_QUERY} AND + storyId is NULL + ` + ); + + assert.strictEqual( + detail, + 'SEARCH messages USING INDEX messages_conversationId_hasExpireTimer_expirationStartTimestamp' + + ' (conversationId=? AND hasExpireTimer=? AND expirationStartTimestamp=?)' + ); + }); + it('uses index efficiently with null start + no storyId condition', () => { + const detail = explain( + db, + sql` + ${UPDATE_WHEN_NULL_START_QUERY} + ` + ); + + assert.strictEqual( + detail, + 'SEARCH messages USING INDEX messages_conversationId_hasExpireTimer_expirationStartTimestamp' + + ' (conversationId=? AND hasExpireTimer=? AND expirationStartTimestamp=?)' + ); + }); + + it('uses index efficiently with lateStart query and no storyId condition', () => { + const detail = explain(db, sql`${UPDATE_WHEN_LATE_START_QUERY}`); + + assert.strictEqual( + detail, + 'SEARCH messages USING INDEX messages_conversationId_hasExpireTimer_expirationStartTimestamp' + + ' (conversationId=? AND hasExpireTimer=? AND expirationStartTimestamp>?)' + ); + }); + + it('uses index efficiently with lateStart query and storyId condition', () => { + const detail = explain( + db, + sql`${UPDATE_WHEN_LATE_START_QUERY} AND + storyId is NULL` + ); + + assert.strictEqual( + detail, + 'SEARCH messages USING INDEX messages_conversationId_hasExpireTimer_expirationStartTimestamp' + + ' (conversationId=? AND hasExpireTimer=? AND expirationStartTimestamp>?)' + ); + }); +}); diff --git a/ts/types/Message2.preload.ts b/ts/types/Message2.preload.ts index e3720c55c1..f77ef33a8a 100644 --- a/ts/types/Message2.preload.ts +++ b/ts/types/Message2.preload.ts @@ -1110,8 +1110,17 @@ export async function migrateBodyAttachmentToDisk( export const isUserMessage = (message: MessageAttributesType): boolean => message.type === 'incoming' || message.type === 'outgoing'; -export const hasExpiration = (message: MessageAttributesType): boolean => { - if (!isUserMessage(message)) { +// NB: if adding more expiring message types, be sure to also update +// getUnreadByConversationAndMarkRead & +// getMessagesUnexpectedlyMissingExpirationStartTimestamp +export const EXPIRING_MESSAGE_TYPES = new Set([ + 'incoming', + 'outgoing', + 'poll-terminate', +]); + +export const isExpiringMessage = (message: MessageAttributesType): boolean => { + if (!EXPIRING_MESSAGE_TYPES.has(message.type)) { return false; }