mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-18 07:36:00 +01:00
Support pollTerminateNotification in backups
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
2
ts/model-types.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
117
ts/test-node/sql/migration_1690_test.node.ts
Normal file
117
ts/test-node/sql/migration_1690_test.node.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user