From 5acdb2f287dcae6867b1c3eba2133b9e2684f366 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:39:42 -0700 Subject: [PATCH] Support pollTerminateNotification in backups --- .../PollTerminateNotification.dom.tsx | 28 +++-- .../conversation/TimelineItem.dom.tsx | 14 ++- ts/jobs/helpers/sendPollTerminate.preload.ts | 3 +- ts/messageModifiers/Polls.preload.ts | 21 ++-- ts/model-types.d.ts | 2 +- ts/models/conversations.preload.ts | 4 +- ts/services/backups/export.preload.ts | 22 ++++ ts/services/backups/import.preload.ts | 25 ++-- ...ll-terminate-notification-timestamp.std.ts | 37 ++++++ ts/sql/migrations/index.node.ts | 2 + ts/state/ducks/composer.preload.ts | 17 ++- ts/state/selectors/message.preload.ts | 9 +- .../backup/integration_test.preload.ts | 6 +- .../backup/non_bubble_test.preload.ts | 20 +++ ts/test-node/sql/migration_1690_test.node.ts | 117 ++++++++++++++++++ ts/types/Polls.dom.ts | 4 + 16 files changed, 280 insertions(+), 51 deletions(-) create mode 100644 ts/sql/migrations/1690-poll-terminate-notification-timestamp.std.ts create mode 100644 ts/test-node/sql/migration_1690_test.node.ts diff --git a/ts/components/conversation/PollTerminateNotification.dom.tsx b/ts/components/conversation/PollTerminateNotification.dom.tsx index 9a9d99686e..2694867732 100644 --- a/ts/components/conversation/PollTerminateNotification.dom.tsx +++ b/ts/components/conversation/PollTerminateNotification.dom.tsx @@ -8,26 +8,40 @@ import { SystemMessage } from './SystemMessage.dom.js'; import { Button, ButtonVariant, ButtonSize } from '../Button.dom.js'; import { UserText } from '../UserText.dom.js'; import { I18n } from '../I18n.dom.js'; +import type { AciString } from '../../types/ServiceId.std.js'; +import { strictAssert } from '../../util/assert.std.js'; +import { isAciString } from '../../util/isAciString.std.js'; -export type PropsType = { +export type PollTerminateNotificationDataType = { sender: ConversationType; pollQuestion: string; - pollMessageId: string; + pollTimestamp: number; conversationId: string; - i18n: LocalizerType; - scrollToPollMessage: (messageId: string, conversationId: string) => unknown; }; +export type PollTerminateNotificationPropsType = + PollTerminateNotificationDataType & { + i18n: LocalizerType; + scrollToPollMessage: ( + pollAuthorAci: AciString, + pollTimestamp: number, + conversationId: string + ) => unknown; + }; export function PollTerminateNotification({ sender, pollQuestion, - pollMessageId, + pollTimestamp, conversationId, i18n, scrollToPollMessage, -}: PropsType): React.JSX.Element { +}: PollTerminateNotificationPropsType): React.JSX.Element { const handleViewPoll = () => { - scrollToPollMessage(pollMessageId, conversationId); + strictAssert( + isAciString(sender.serviceId), + 'poll sender serviceId must be ACI' + ); + scrollToPollMessage(sender.serviceId, pollTimestamp, conversationId); }; const message = sender.isMe ? ( diff --git a/ts/components/conversation/TimelineItem.dom.tsx b/ts/components/conversation/TimelineItem.dom.tsx index 4d82a79ab6..9be0047f1e 100644 --- a/ts/components/conversation/TimelineItem.dom.tsx +++ b/ts/components/conversation/TimelineItem.dom.tsx @@ -53,7 +53,7 @@ import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileC import { ProfileChangeNotification } from './ProfileChangeNotification.dom.js'; import type { PropsType as PaymentEventNotificationPropsType } from './PaymentEventNotification.dom.js'; import { PaymentEventNotification } from './PaymentEventNotification.dom.js'; -import type { PropsType as PollTerminateNotificationPropsType } from './PollTerminateNotification.dom.js'; +import type { PollTerminateNotificationDataType } from './PollTerminateNotification.dom.js'; import { PollTerminateNotification } from './PollTerminateNotification.dom.js'; import type { PropsDataType as ConversationMergeNotificationPropsType } from './ConversationMergeNotification.dom.js'; import { ConversationMergeNotification } from './ConversationMergeNotification.dom.js'; @@ -70,6 +70,7 @@ import { import type { MessageRequestState } from './MessageRequestActionsConfirmation.dom.js'; import type { MessageInteractivity } from './Message.dom.js'; import type { PinMessageData } from '../../model-types.js'; +import type { AciString } from '../../types/ServiceId.std.js'; type CallHistoryType = { type: 'callHistory'; @@ -165,10 +166,7 @@ type MessageRequestResponseNotificationType = { }; type PollTerminateNotificationType = { type: 'pollTerminate'; - data: Omit< - PollTerminateNotificationPropsType, - 'i18n' | 'scrollToPollMessage' - >; + data: PollTerminateNotificationDataType; }; export type TimelineItemType = ( @@ -210,7 +208,11 @@ type PropsLocalType = { isNextItemCallingNotification: boolean; isTargeted: boolean; scrollToPinnedMessage: (pinMessage: PinMessageData) => void; - scrollToPollMessage: (messageId: string, conversationId: string) => unknown; + scrollToPollMessage: ( + pollAuthorAci: AciString, + pollTimestamp: number, + conversationId: string + ) => unknown; targetMessage: (messageId: string, conversationId: string) => unknown; shouldRenderDateHeader: boolean; onOpenEditNicknameAndNoteModal: (contactId: string) => void; diff --git a/ts/jobs/helpers/sendPollTerminate.preload.ts b/ts/jobs/helpers/sendPollTerminate.preload.ts index 48ae804648..530b8f717f 100644 --- a/ts/jobs/helpers/sendPollTerminate.preload.ts +++ b/ts/jobs/helpers/sendPollTerminate.preload.ts @@ -307,7 +307,8 @@ async function markTerminateFailed( poll.terminatedAt, m => m.get('type') === 'poll-terminate' && - m.get('pollTerminateNotification')?.pollMessageId === message.id + m.get('pollTerminateNotification')?.pollTimestamp === + message.attributes.timestamp ); if (notificationMessage) { diff --git a/ts/messageModifiers/Polls.preload.ts b/ts/messageModifiers/Polls.preload.ts index 20e48640ad..2fe6b7555a 100644 --- a/ts/messageModifiers/Polls.preload.ts +++ b/ts/messageModifiers/Polls.preload.ts @@ -573,19 +573,18 @@ export async function handlePollTerminate( `Poll ${getMessageIdForLogging(message.attributes)} terminated at ${terminate.timestamp}` ); + await conversation.addPollTerminateNotification({ + pollQuestion: poll.question, + pollTimestamp: message.attributes.timestamp, + terminatorId: terminate.fromConversationId, + timestamp: terminate.timestamp, + isMeTerminating: isMe(author.attributes), + expireTimer: terminate.expireTimer, + expirationStartTimestamp: terminate.expirationStartTimestamp, + }); + if (shouldPersist) { await window.MessageCache.saveMessage(message.attributes); - - await conversation.addPollTerminateNotification({ - pollQuestion: poll.question, - pollMessageId: message.id, - 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/model-types.d.ts b/ts/model-types.d.ts index 8d287c4625..551b64a844 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -223,7 +223,7 @@ export type MessageAttributesType = { poll?: PollMessageAttribute; pollTerminateNotification?: { question: string; - pollMessageId: string; + pollTimestamp: number; }; // This field will only be set to true for outgoing messages hasUnreadPollVotes?: boolean; diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index 2cf39c9e3b..d15596952e 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -3551,7 +3551,7 @@ export class ConversationModel { async addPollTerminateNotification(params: { pollQuestion: string; - pollMessageId: string; + pollTimestamp: number; terminatorId: string; timestamp: number; isMeTerminating: boolean; @@ -3573,7 +3573,7 @@ export class ConversationModel { sourceServiceId: terminatorServiceId, pollTerminateNotification: { question: params.pollQuestion, - pollMessageId: params.pollMessageId, + pollTimestamp: params.pollTimestamp, }, readStatus: params.isMeTerminating ? ReadStatus.Read : ReadStatus.Unread, seenStatus: params.isMeTerminating ? SeenStatus.Seen : SeenStatus.Unseen, diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index e8da223e94..42cc5bcd0d 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -84,6 +84,7 @@ import { isGroupV2Change, isKeyChange, isNormalBubble, + isPollTerminate, isPhoneNumberDiscovery, isProfileChange, isTapToView, @@ -2192,6 +2193,27 @@ export class BackupExportStream extends Readable { return { kind: NonBubbleResultKind.Directionless, patch }; } + if (isPollTerminate(message)) { + const pollTerminate = message.pollTerminateNotification; + if (!pollTerminate) { + log.warn( + `${logId}: poll-terminate message missing pollTerminateNotification data` + ); + return { kind: NonBubbleResultKind.Drop }; + } + + updateMessage.update = { + pollTerminate: { + question: pollTerminate.question, + targetSentTimestamp: getSafeLongFromTimestamp( + pollTerminate.pollTimestamp + ), + }, + }; + + return { kind: NonBubbleResultKind.Directionless, patch }; + } + if (isProfileChange(message)) { if (!message.profileChange?.newName || !message.profileChange?.oldName) { return { kind: NonBubbleResultKind.Drop }; diff --git a/ts/services/backups/import.preload.ts b/ts/services/backups/import.preload.ts index c51e78902f..c5a79585a1 100644 --- a/ts/services/backups/import.preload.ts +++ b/ts/services/backups/import.preload.ts @@ -182,8 +182,6 @@ type ChatItemParseResult = { additionalMessages: Array>; }; -const SKIP = 'SKIP' as const; - function phoneToContactFormType( type?: Backups.ContactAttachment.Phone['type'] ): ContactFormType { @@ -1791,10 +1789,6 @@ export class BackupImportStream extends Writable { timestamp, }); - if (result === SKIP) { - return; - } - if (!result) { throw new Error(`${logId}: fromNonBubbleChat item returned nothing!`); } @@ -2554,7 +2548,7 @@ export class BackupImportStream extends Writable { conversation: ConversationAttributesType; timestamp: number; } - ): Promise { + ): Promise { const { timestamp } = options; const logId = `fromChatItemToNonBubble(${timestamp})`; @@ -2818,7 +2812,7 @@ export class BackupImportStream extends Writable { conversation: ConversationAttributesType; timestamp: number; } - ): Promise { + ): Promise { const { aboutMe, author, conversation } = options; const { update } = updateMessage; @@ -3113,9 +3107,18 @@ export class BackupImportStream extends Writable { } if (update.pollTerminate) { - // TODO (DESKTOP-9282) - log.warn('Skipping pollTerminate update (not yet supported)'); - return SKIP; + const { targetSentTimestamp, question } = update.pollTerminate; + + return { + message: { + type: 'poll-terminate', + pollTerminateNotification: { + question, + pollTimestamp: toNumber(targetSentTimestamp), + }, + }, + additionalMessages: [], + }; } return undefined; diff --git a/ts/sql/migrations/1690-poll-terminate-notification-timestamp.std.ts b/ts/sql/migrations/1690-poll-terminate-notification-timestamp.std.ts new file mode 100644 index 0000000000..cd65db80f2 --- /dev/null +++ b/ts/sql/migrations/1690-poll-terminate-notification-timestamp.std.ts @@ -0,0 +1,37 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { LoggerType } from '../../types/Logging.std.js'; +import type { WritableDB } from '../Interface.std.js'; +import { sql } from '../util.std.js'; + +export default function updateToSchemaVersion1690( + db: WritableDB, + logger: LoggerType +): void { + const [query, params] = sql` + UPDATE messages AS message + SET json = json_remove( + json_set( + message.json, + '$.pollTerminateNotification.pollTimestamp', + COALESCE( + ( + SELECT poll.timestamp + FROM messages AS poll + WHERE poll.id = message.json ->> '$.pollTerminateNotification.pollMessageId' + ), + 0 + ) + ), + '$.pollTerminateNotification.pollMessageId' + ) + WHERE + message.type IS 'poll-terminate' AND + message.json -> '$.pollTerminateNotification' IS NOT NULL; + `; + + const result = db.prepare(query).run(params); + + logger.info(`Updated ${result.changes} poll terminate notifications`); +} diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts index 0668af4079..5a818833ae 100644 --- a/ts/sql/migrations/index.node.ts +++ b/ts/sql/migrations/index.node.ts @@ -145,6 +145,7 @@ import updateToSchemaVersion1650 from './1650-protected-attachments.std.js'; import updateToSchemaVersion1660 from './1660-protected-attachments-non-unique.std.js'; import updateToSchemaVersion1670 from './1670-drop-call-link-epoch.std.js'; import updateToSchemaVersion1680 from './1680-cleanup-empty-strings.std.js'; +import updateToSchemaVersion1690 from './1690-poll-terminate-notification-timestamp.std.js'; import { DataWriter } from '../Server.node.js'; import { strictAssert } from '../../util/assert.std.js'; @@ -1652,6 +1653,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1660, update: updateToSchemaVersion1660 }, { version: 1670, update: updateToSchemaVersion1670 }, { version: 1680, update: updateToSchemaVersion1680 }, + { version: 1690, update: updateToSchemaVersion1690 }, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/state/ducks/composer.preload.ts b/ts/state/ducks/composer.preload.ts index f31f94292a..1edb83b152 100644 --- a/ts/state/ducks/composer.preload.ts +++ b/ts/state/ducks/composer.preload.ts @@ -108,6 +108,7 @@ import { getActivePanel, getSelectedConversationId, } from '../selectors/nav.std.js'; +import { isPoll } from '../../messages/helpers.std.js'; const { debounce, isEqual } = lodash; @@ -451,7 +452,8 @@ function scrollToPinnedMessage( } function scrollToPollMessage( - pollMessageId: string, + pollAuthorAci: AciString, + pollTimestamp: number, conversationId: string ): ThunkAction< void, @@ -460,9 +462,16 @@ function scrollToPollMessage( ShowToastActionType | ScrollToMessageActionType > { return async (dispatch, getState) => { - const pollMessage = await getMessageById(pollMessageId); + const ourAci = itemStorage.user.getCheckedAci(); - if (!pollMessage) { + const pollMessage = await DataReader.getMessageByAuthorAciAndSentAt( + ourAci, + pollAuthorAci, + pollTimestamp, + { includeEdits: true } + ); + + if (!pollMessage || !isPoll(pollMessage)) { dispatch({ type: SHOW_TOAST, payload: { @@ -476,7 +485,7 @@ function scrollToPollMessage( return; } - scrollToMessage(conversationId, pollMessageId)( + scrollToMessage(conversationId, pollMessage.id)( dispatch, getState, undefined diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index dce3096329..a867c8414e 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -177,6 +177,7 @@ import { LONG_MESSAGE } from '../../types/MIME.std.js'; import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification.dom.js'; import type { PinnedMessageNotificationData } from '../../components/conversation/pinned-messages/PinnedMessageNotification.dom.js'; import { isPinnedMessagesSendEnabled } from '../../util/isPinnedMessagesEnabled.dom.js'; +import type { PollTerminateNotificationDataType } from '../../components/conversation/PollTerminateNotification.dom.js'; const { groupBy, isEmpty, isNumber, isObject, map } = lodash; @@ -1278,6 +1279,7 @@ export function isNormalBubble(message: MessageWithUIFieldsType): boolean { !isPhoneNumberDiscovery(message) && !isTitleTransitionNotification(message) && !isPinnedMessageNotification(message) && + !isPollTerminate(message) && !isProfileChange(message) && !isUniversalTimerNotification(message) && !isUnsupportedMessage(message) && @@ -1811,7 +1813,7 @@ export function isPollTerminate(message: MessageWithUIFieldsType): boolean { function getPropsForPollTerminate( message: MessageWithUIFieldsType, { conversationSelector }: GetPropsForBubbleOptions -) { +): PollTerminateNotificationDataType { const { pollTerminateNotification, sourceServiceId, conversationId } = message; @@ -1822,11 +1824,12 @@ function getPropsForPollTerminate( } const sender = conversationSelector(sourceServiceId); + const { question, pollTimestamp } = pollTerminateNotification; return { sender, - pollQuestion: pollTerminateNotification.question, - pollMessageId: pollTerminateNotification.pollMessageId, + pollQuestion: question, + pollTimestamp, conversationId, }; } diff --git a/ts/test-electron/backup/integration_test.preload.ts b/ts/test-electron/backup/integration_test.preload.ts index 03f7b24363..d316f0d457 100644 --- a/ts/test-electron/backup/integration_test.preload.ts +++ b/ts/test-electron/backup/integration_test.preload.ts @@ -79,11 +79,7 @@ describe('backup/integration', () => { const actualString = actual.comparableString(); const expectedString = expected.comparableString(); - if ( - expectedString.includes('ReleaseChannelDonationRequest') || - // TODO (DESKTOP-9209) roundtrip these frames when feature is added - fullPath.includes('poll_terminate') - ) { + if (expectedString.includes('ReleaseChannelDonationRequest')) { // Skip the unsupported tests return; } diff --git a/ts/test-electron/backup/non_bubble_test.preload.ts b/ts/test-electron/backup/non_bubble_test.preload.ts index 3f49742cf8..a94f2b3aaf 100644 --- a/ts/test-electron/backup/non_bubble_test.preload.ts +++ b/ts/test-electron/backup/non_bubble_test.preload.ts @@ -578,6 +578,26 @@ describe('backup/non-bubble messages', () => { ]); }); + it('roundtrips poll terminate notification with pollTimestamp', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'poll-terminate', + received_at: 2, + sent_at: 2, + timestamp: 2, + sourceServiceId: CONTACT_A, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, + pollTerminateNotification: { + question: 'Question?', + pollTimestamp: 12345, + }, + }, + ]); + }); + it('creates a tombstone for gv1 update in gv2 group', async () => { await asymmetricRoundtripHarness( [ diff --git a/ts/test-node/sql/migration_1690_test.node.ts b/ts/test-node/sql/migration_1690_test.node.ts new file mode 100644 index 0000000000..fd4f788512 --- /dev/null +++ b/ts/test-node/sql/migration_1690_test.node.ts @@ -0,0 +1,117 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import type { WritableDB } from '../../sql/Interface.std.js'; +import { + createDB, + getTableData, + insertData, + updateToVersion, +} from './helpers.node.js'; + +describe('SQL/updateToSchemaVersion1690', () => { + let db: WritableDB; + + beforeEach(() => { + db = createDB(); + updateToVersion(db, 1680); + }); + + afterEach(() => { + db.close(); + }); + + it('migrates pollTerminateNotification.pollMessageId to pollTimestamp', () => { + insertData(db, 'messages', [ + { + id: 'poll-message', + conversationId: 'conversation', + type: 'incoming', + timestamp: 12345, + sent_at: 99999, + json: { id: 'poll-message', poll: { question: 'question' } }, + }, + { + id: 'terminate-legacy', + conversationId: 'conversation', + type: 'poll-terminate', + json: { + id: 'terminate-legacy', + pollTerminateNotification: { + question: 'question', + pollMessageId: 'poll-message', + }, + }, + }, + { + id: 'terminate-missing-target', + conversationId: 'conversation', + type: 'poll-terminate', + json: { + id: 'terminate-missing-target', + pollTerminateNotification: { + question: 'question', + pollMessageId: 'missing', + }, + }, + }, + { + id: 'terminate-without-id', + conversationId: 'conversation', + type: 'poll-terminate', + json: { + id: 'terminate-without-id', + pollTerminateNotification: { + question: 'question', + }, + }, + }, + { + id: 'terminate-without-notification', + conversationId: 'conversation', + type: 'poll-terminate', + json: { + id: 'terminate-without-notification', + }, + }, + ]); + + updateToVersion(db, 1690); + + assert.sameDeepMembers( + getTableData(db, 'messages').map(row => row.json), + [ + { + id: 'poll-message', + poll: { question: 'question' }, + }, + { + id: 'terminate-legacy', + pollTerminateNotification: { + question: 'question', + pollTimestamp: 12345, + }, + }, + { + id: 'terminate-missing-target', + pollTerminateNotification: { + question: 'question', + pollTimestamp: 0, + }, + }, + { + id: 'terminate-without-id', + pollTerminateNotification: { + question: 'question', + pollTimestamp: 0, + }, + }, + { + id: 'terminate-without-notification', + }, + ] + ); + }); +}); diff --git a/ts/types/Polls.dom.ts b/ts/types/Polls.dom.ts index d66e0ba454..85869985b4 100644 --- a/ts/types/Polls.dom.ts +++ b/ts/types/Polls.dom.ts @@ -101,6 +101,10 @@ export type PollMessageAttribute = { options: ReadonlyArray; allowMultiple: boolean; votes?: ReadonlyArray; + /** + * The value of the terminatedAt timestamp is not reliable for polls that are imported + * from backup; only use this field to determine if a poll has been ended or not + */ terminatedAt?: number; terminateSendStatus?: PollTerminateSendStatus; };