mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-05 01:10:49 +00:00
Expire poll terminate chat events
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
`
|
||||
)
|
||||
|
||||
20
ts/sql/migrations/1530-update-expiring-index.std.ts
Normal file
20
ts/sql/migrations/1530-update-expiring-index.std.ts
Normal 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);
|
||||
`);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
93
ts/test-node/sql/migration_1530_test.node.ts
Normal file
93
ts/test-node/sql/migration_1530_test.node.ts
Normal 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>?)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user