Support pollTerminateNotification in backups

This commit is contained in:
trevor-signal
2026-03-13 13:39:42 -07:00
committed by GitHub
parent 54053d7ff6
commit 5acdb2f287
16 changed files with 280 additions and 51 deletions

View File

@@ -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;
};
export type PollTerminateNotificationPropsType =
PollTerminateNotificationDataType & {
i18n: LocalizerType;
scrollToPollMessage: (messageId: string, conversationId: string) => unknown;
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 ? (

View File

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

View File

@@ -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) {

View File

@@ -573,12 +573,9 @@ export async function handlePollTerminate(
`Poll ${getMessageIdForLogging(message.attributes)} terminated at ${terminate.timestamp}`
);
if (shouldPersist) {
await window.MessageCache.saveMessage(message.attributes);
await conversation.addPollTerminateNotification({
pollQuestion: poll.question,
pollMessageId: message.id,
pollTimestamp: message.attributes.timestamp,
terminatorId: terminate.fromConversationId,
timestamp: terminate.timestamp,
isMeTerminating: isMe(author.attributes),
@@ -586,6 +583,8 @@ export async function handlePollTerminate(
expirationStartTimestamp: terminate.expirationStartTimestamp,
});
if (shouldPersist) {
await window.MessageCache.saveMessage(message.attributes);
window.reduxActions.conversations.markOpenConversationRead(conversation.id);
}
}

2
ts/model-types.d.ts vendored
View File

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

View File

@@ -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,

View File

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

View File

@@ -182,8 +182,6 @@ type ChatItemParseResult = {
additionalMessages: Array<Partial<MessageAttributesType>>;
};
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<ChatItemParseResult | undefined | typeof SKIP> {
): Promise<ChatItemParseResult | undefined> {
const { timestamp } = options;
const logId = `fromChatItemToNonBubble(${timestamp})`;
@@ -2818,7 +2812,7 @@ export class BackupImportStream extends Writable {
conversation: ConversationAttributesType;
timestamp: number;
}
): Promise<ChatItemParseResult | undefined | typeof SKIP> {
): Promise<ChatItemParseResult | undefined> {
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;

View File

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

View File

@@ -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<SchemaUpdateType> = [
{ version: 1660, update: updateToSchemaVersion1660 },
{ version: 1670, update: updateToSchemaVersion1670 },
{ version: 1680, update: updateToSchemaVersion1680 },
{ version: 1690, update: updateToSchemaVersion1690 },
];
export class DBVersionFromFutureError extends Error {

View File

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

View File

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

View File

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

View File

@@ -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(
[

View File

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

View File

@@ -101,6 +101,10 @@ export type PollMessageAttribute = {
options: ReadonlyArray<string>;
allowMultiple: boolean;
votes?: ReadonlyArray<MessagePollVoteType>;
/**
* 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;
};