Expire poll terminate chat events

This commit is contained in:
trevor-signal
2025-11-12 11:02:36 -05:00
committed by GitHub
parent 7dd865904e
commit fe2a012bc8
12 changed files with 195 additions and 32 deletions

View File

@@ -2530,7 +2530,7 @@ export async function startApp(): Promise<void> {
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<void> {
targetTimestamp: parsedTerm.data.targetTimestamp,
receivedAtDate: data.receivedAtDate,
timestamp,
expireTimer,
expirationStartTimestamp: undefined,
};
drop(Polls.onPollTerminate(attributes));
@@ -3026,7 +3028,7 @@ export async function startApp(): Promise<void> {
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<void> {
source: Polls.PollSource.FromSync,
targetTimestamp: parsedTerm.data.targetTimestamp,
receivedAtDate: data.receivedAtDate,
expireTimer,
expirationStartTimestamp: data.expirationStartTimestamp,
timestamp,
};

View File

@@ -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<string, PollVoteAttributesType>();
@@ -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);

View File

@@ -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<void> {
serviceId: item.sourceServiceId,
reason: logId,
});
return isIncoming(item) && sender?.id === readSync.senderId;
return (
(isIncoming(item) || isPollTerminate(item)) &&
sender?.id === readSync.senderId
);
});
if (!found) {

View File

@@ -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<void> {
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<void> {
: messageForNotification.storyId,
notificationIconUrl: url,
notificationIconAbsolutePath: absolutePath,
isExpiringMessage,
isExpiringMessage: isExpiringMessage(messageForNotification),
message: getNotificationTextForMessage(messageForNotification),
messageId,
reaction: reaction

View File

@@ -3501,6 +3501,8 @@ export class ConversationModel {
terminatorId: string;
timestamp: number;
isMeTerminating: boolean;
expireTimer: DurationInSeconds | undefined;
expirationStartTimestamp: number | undefined;
}): Promise<void> {
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 });

View File

@@ -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 });

View File

@@ -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')
);
`
)

View File

@@ -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);
`);
}

View File

@@ -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<SchemaUpdateType> = [
{ version: 1500, update: updateToSchemaVersion1500 },
{ version: 1510, update: updateToSchemaVersion1510 },
{ version: 1520, update: updateToSchemaVersion1520 },
{ version: 1530, update: updateToSchemaVersion1530 },
];
export class DBVersionFromFutureError extends Error {

View File

@@ -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'
);

View File

@@ -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>?)'
);
});
});

View File

@@ -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;
}