diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 940d9d3a59..503907c03d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,7 +234,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: repository: 'signalapp/Signal-Message-Backup-Tests' - ref: 'ae41153c5ac776b138b778b82fa593be23b3a14c' + ref: '455fbe5854bd3be5002f17ae929a898c0975adc4' path: 'backup-integration-tests' - run: xvfb-run --auto-servernum pnpm run test-electron diff --git a/protos/Backups.proto b/protos/Backups.proto index 5567acfde7..241c48024c 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -428,6 +428,7 @@ message ChatItem { GiftBadge giftBadge = 17; ViewOnceMessage viewOnceMessage = 18; DirectStoryReplyMessage directStoryReplyMessage = 19; // group story reply messages are not backed up + Poll poll = 20; } } @@ -760,6 +761,7 @@ message Quote { NORMAL = 1; GIFT_BADGE = 2; VIEW_ONCE = 3; + POLL = 4; } message QuotedAttachment { @@ -806,6 +808,31 @@ message Reaction { uint64 sortOrder = 4; } +message Poll { + + message PollOption { + + message PollVote { + uint64 voterId = 1; // A direct reference to Recipient proto id. Must be self or contact. + uint32 voteCount = 2; // Tracks how many times you voted. + } + + string option = 1; // Between 1-100 characters + repeated PollVote votes = 2; + } + + string question = 1; // Between 1-100 characters + bool allowMultiple = 2; + repeated PollOption options = 3; // At least two + bool hasEnded = 4; + repeated Reaction reactions = 5; +} + +message PollTerminateUpdate { + uint64 targetSentTimestamp = 1; + string question = 2; // Between 1-100 characters +} + message ChatUpdateMessage { // If unset, importers should ignore the update message without throwing an error. oneof update { @@ -818,6 +845,7 @@ message ChatUpdateMessage { IndividualCall individualCall = 7; GroupCall groupCall = 8; LearnedProfileChatUpdate learnedProfileChange = 9; + PollTerminateUpdate pollTerminate = 10; } } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 2c0f55550c..74900c43c4 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -103,6 +103,7 @@ export type QuotedMessageType = { // from backup id: number | null; isGiftBadge?: boolean; + isPoll?: boolean; isViewOnce: boolean; referencedMessageNotFound: boolean; text?: string; diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index 5b9668f523..89cafcd596 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -1499,6 +1499,67 @@ export class BackupExportStream extends Readable { }); result.revisions = await this.#toChatItemRevisions(result, message); + } else if (message.poll) { + const { poll } = message; + const pollMessage = new Backups.Poll(); + + pollMessage.question = poll.question; + pollMessage.allowMultiple = poll.allowMultiple; + pollMessage.hasEnded = poll.terminatedAt != null; + + pollMessage.options = poll.options.map((optionText, optionIndex) => { + const pollOption = new Backups.Poll.PollOption(); + pollOption.option = optionText; + + const votesForThisOption = new Map(); + + if (poll.votes) { + for (const vote of poll.votes) { + // Skip votes that have not been sent + if (vote.sendStateByConversationId) { + continue; + } + + // If we somehow have multiple votes from the same person + // (shouldn't happen, just in case) only keep the highest voteCount + const maybeExistingVoteFromThisConversation = + votesForThisOption.get(vote.fromConversationId); + if ( + vote.optionIndexes.includes(optionIndex) && + (!maybeExistingVoteFromThisConversation || + vote.voteCount > maybeExistingVoteFromThisConversation) + ) { + votesForThisOption.set(vote.fromConversationId, vote.voteCount); + } + } + } + + pollOption.votes = Array.from(votesForThisOption.entries()).map( + ([conversationId, voteCount]) => { + const pollVote = new Backups.Poll.PollOption.PollVote(); + + const voterConvo = + window.ConversationController.get(conversationId); + if (voterConvo) { + pollVote.voterId = this.#getOrPushPrivateRecipient( + voterConvo.attributes + ); + } + + pollVote.voteCount = voteCount; + return pollVote; + } + ); + + return pollOption; + }); + + const reactions = this.#getMessageReactions(message); + if (reactions != null) { + pollMessage.reactions = reactions; + } + + result.poll = pollMessage; } else { result.standardMessage = await this.#toStandardMessage({ message, @@ -2451,6 +2512,8 @@ export class BackupExportStream extends Readable { quoteType = Backups.Quote.Type.GIFT_BADGE; } else if (quote.isViewOnce) { quoteType = Backups.Quote.Type.VIEW_ONCE; + } else if (quote.isPoll) { + quoteType = Backups.Quote.Type.POLL; } else { quoteType = Backups.Quote.Type.NORMAL; if (quote.text == null && quote.attachments.length === 0) { diff --git a/ts/services/backups/import.preload.ts b/ts/services/backups/import.preload.ts index 5e9a4b6567..4743bbd12b 100644 --- a/ts/services/backups/import.preload.ts +++ b/ts/services/backups/import.preload.ts @@ -180,6 +180,8 @@ type ChatItemParseResult = { additionalMessages: Array>; }; +const SKIP = 'SKIP' as const; + function phoneToContactFormType( type: Backups.ContactAttachment.Phone.Type | null | undefined ): ContactFormType { @@ -1564,6 +1566,63 @@ export class BackupImportStream extends Writable { storyAuthorAci ), }; + } else if (item.poll) { + const { poll } = item; + + const votesByVoter = new Map< + string, + { + fromConversationId: string; + optionIndexes: Array; + voteCount: number; + timestamp: number; + } + >(); + + poll.options?.forEach((option, optionIndex) => { + option.votes?.forEach(vote => { + if (!vote.voterId) { + return; + } + + const conversation = this.#recipientIdToConvo.get( + vote.voterId.toNumber() + ); + if (!conversation) { + log.warn(`${logId}: Poll vote has unknown voterId ${vote.voterId}`); + return; + } + + const conversationId = conversation.id; + + let voterRecord = votesByVoter.get(conversationId); + if (!voterRecord) { + voterRecord = { + fromConversationId: conversationId, + optionIndexes: [], + voteCount: vote.voteCount ?? 1, + timestamp, + }; + votesByVoter.set(conversationId, voterRecord); + } + + voterRecord.optionIndexes.push(optionIndex); + }); + }); + + const votes = Array.from(votesByVoter.values()); + + attributes = { + ...attributes, + poll: { + question: poll.question ?? '', + options: poll.options?.map(option => option.option ?? '') ?? [], + allowMultiple: poll.allowMultiple ?? false, + votes: votes.length > 0 ? votes : undefined, + terminatedAt: poll.hasEnded ? Number(item.dateSent) : undefined, + }, + reactions: this.#fromReactions(poll.reactions), + }; } else { const result = await this.#fromNonBubbleChatItem(item, { aboutMe, @@ -1572,6 +1631,10 @@ export class BackupImportStream extends Writable { timestamp, }); + if (result === SKIP) { + return; + } + if (!result) { throw new Error(`${logId}: fromNonBubbleChat item returned nothing!`); } @@ -2159,6 +2222,7 @@ export class BackupImportStream extends Writable { text: dropNull(quote.text?.body), bodyRanges: this.#fromBodyRanges(quote.text), isGiftBadge: quote.type === Backups.Quote.Type.GIFT_BADGE, + isPoll: quote.type === Backups.Quote.Type.POLL ? true : undefined, isViewOnce: quote.type === Backups.Quote.Type.VIEW_ONCE, attachments: quote.attachments?.map(quotedAttachment => { @@ -2242,7 +2306,7 @@ export class BackupImportStream extends Writable { conversation: ConversationAttributesType; timestamp: number; } - ): Promise { + ): Promise { const { timestamp } = options; const logId = `fromChatItemToNonBubble(${timestamp})`; @@ -2477,7 +2541,7 @@ export class BackupImportStream extends Writable { conversation: ConversationAttributesType; timestamp: number; } - ): Promise { + ): Promise { const { aboutMe, author, conversation } = options; if (updateMessage.groupChange) { @@ -2733,6 +2797,11 @@ export class BackupImportStream extends Writable { }; } + if (updateMessage.pollTerminate) { + log.info('Skipping pollTerminate update (not yet supported)'); + return SKIP; + } + return undefined; } diff --git a/ts/test-electron/backup/bubble_test.preload.ts b/ts/test-electron/backup/bubble_test.preload.ts index 8666e932dd..c46ad77852 100644 --- a/ts/test-electron/backup/bubble_test.preload.ts +++ b/ts/test-electron/backup/bubble_test.preload.ts @@ -1184,4 +1184,320 @@ describe('backup/bubble messages', () => { ]); }); }); + + describe('polls', () => { + const GROUP_ID = Bytes.toBase64(getRandomBytes(32)); + let group: ConversationModel | undefined; + + let basePollMessage: MessageAttributesType; + + beforeEach(async () => { + group = await window.ConversationController.getOrCreateAndWait( + GROUP_ID, + 'group', + { + groupVersion: 2, + masterKey: Bytes.toBase64(getRandomBytes(32)), + name: 'Poll Test Group', + active_at: 1, + } + ); + + strictAssert(group, 'group must exist'); + + basePollMessage = { + conversationId: group.id, + id: generateGuid(), + type: 'incoming', + received_at: 3, + received_at_ms: 3, + sent_at: 3, + sourceServiceId: CONTACT_A, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + unidentifiedDeliveryReceived: true, + timestamp: 3, + poll: { + question: '', + options: [], + allowMultiple: false, + votes: undefined, + terminatedAt: undefined, + }, + }; + }); + + it('roundtrips poll with no votes', async () => { + await symmetricRoundtripHarness([ + { + ...basePollMessage, + poll: { + question: 'How do you feel about unit testing?', + options: ['yay', 'ok', 'nay'], + allowMultiple: false, + }, + }, + ]); + }); + + it('roundtrips poll with single vote', async () => { + await symmetricRoundtripHarness([ + { + ...basePollMessage, + poll: { + question: 'How do you feel about unit testing?', + options: ['yay', 'ok', 'nay'], + allowMultiple: false, + votes: [ + { + fromConversationId: contactA.id, + optionIndexes: [0], // Voted for "yay" + voteCount: 1, + timestamp: 3, + }, + ], + }, + }, + ]); + }); + + it('roundtrips poll with multiple voters', async () => { + await symmetricRoundtripHarness([ + { + ...basePollMessage, + poll: { + question: 'Pizza toppings?', + options: ['pepperoni', 'mushrooms', 'pineapple'], + allowMultiple: false, + votes: [ + { + fromConversationId: contactA.id, + optionIndexes: [0], // contactA voted for pepperoni + voteCount: 2, // Changed their vote twice + timestamp: 3, + }, + { + fromConversationId: contactB.id, + optionIndexes: [2], // contactB voted for pineapple + voteCount: 1, + timestamp: 3, + }, + ], + }, + }, + ]); + }); + + it('roundtrips poll with multiple selections', async () => { + await symmetricRoundtripHarness([ + { + ...basePollMessage, + poll: { + question: 'Which features do you want?', + options: ['dark mode', 'better search', 'polls', 'voice notes'], + allowMultiple: true, + votes: [ + { + fromConversationId: contactA.id, + optionIndexes: [0, 2, 3], // Selected dark mode, polls, and voice notes + voteCount: 1, + timestamp: 3, + }, + ], + }, + }, + ]); + }); + + it('roundtrips ended poll', async () => { + const pollData = { + poll: { + question: 'This poll is closed', + options: ['option1', 'option2'], + allowMultiple: false, + votes: [ + { + fromConversationId: contactA.id, + optionIndexes: [0], + voteCount: 1, + timestamp: 3, + }, + ], + }, + }; + + await asymmetricRoundtripHarness( + [ + { + ...basePollMessage, + ...pollData, + poll: { + ...pollData.poll, + terminatedAt: 5, // Original termination timestamp + }, + }, + ], + [ + { + ...basePollMessage, + ...pollData, + poll: { + ...pollData.poll, + terminatedAt: 3, // After roundtrip, set to message timestamp + }, + }, + ] + ); + }); + + it('roundtrips outgoing poll', async () => { + await symmetricRoundtripHarness([ + { + ...basePollMessage, + type: 'outgoing', + sourceServiceId: OUR_ACI, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, + unidentifiedDeliveryReceived: false, // Outgoing messages default to false + sendStateByConversationId: { + [contactA.id]: { + status: SendStatus.Delivered, + }, + }, + poll: { + question: 'Meeting time?', + options: ['10am', '2pm', '4pm'], + allowMultiple: false, + votes: [ + { + fromConversationId: contactA.id, + optionIndexes: [1], + voteCount: 1, + timestamp: 3, + }, + ], + }, + }, + ]); + }); + + it('excludes pending votes from backup', async () => { + strictAssert(group, 'group must exist'); + const ourConversation = window.ConversationController.get(OUR_ACI); + strictAssert(ourConversation, 'our conversation must exist'); + + await asymmetricRoundtripHarness( + [ + { + ...basePollMessage, + poll: { + question: 'Test question?', + options: ['yes', 'no'], + allowMultiple: false, + votes: [ + { + fromConversationId: contactA.id, + optionIndexes: [0], + voteCount: 1, + timestamp: 3, + // No sendStateByConversationId - this vote is sent + }, + { + fromConversationId: ourConversation.id, + optionIndexes: [1], + voteCount: 1, + timestamp: 3, + // This vote is still pending, should NOT be in backup + sendStateByConversationId: { + [contactA.id]: { + status: SendStatus.Pending, + updatedAt: 3, + }, + }, + }, + ], + }, + }, + ], + [ + { + ...basePollMessage, + poll: { + question: 'Test question?', + options: ['yes', 'no'], + allowMultiple: false, + votes: [ + { + fromConversationId: contactA.id, + optionIndexes: [0], + voteCount: 1, + timestamp: 3, + // Only the sent vote should remain after roundtrip + }, + // The pending vote should be excluded from the backup + ], + }, + }, + ] + ); + }); + + it('roundtrips poll with reactions', async () => { + await symmetricRoundtripHarness([ + { + ...basePollMessage, + poll: { + question: 'React to this poll?', + options: ['yes', 'no'], + allowMultiple: false, + }, + reactions: [ + { + emoji: '👍', + fromId: contactA.id, + targetTimestamp: 3, + timestamp: 3, + }, + { + emoji: '❤️', + fromId: contactB.id, + targetTimestamp: 3, + timestamp: 3, + }, + ], + }, + ]); + }); + + it('roundtrips quote of poll', async () => { + await symmetricRoundtripHarness([ + { + ...basePollMessage, + poll: { + question: 'Original poll?', + options: ['option1', 'option2'], + allowMultiple: false, + }, + }, + { + ...basePollMessage, + id: generateGuid(), // Generate new ID to avoid duplicate + sent_at: 4, + timestamp: 4, + body: 'Replying to poll', + poll: undefined, + quote: { + id: 3, + authorAci: CONTACT_A, + text: 'Original poll?', + attachments: [], + isGiftBadge: false, + isPoll: true, + isViewOnce: false, + referencedMessageNotFound: false, + }, + }, + ]); + }); + }); }); diff --git a/ts/test-electron/backup/helpers.preload.ts b/ts/test-electron/backup/helpers.preload.ts index e2c0e893fb..2c04838fdf 100644 --- a/ts/test-electron/backup/helpers.preload.ts +++ b/ts/test-electron/backup/helpers.preload.ts @@ -79,6 +79,7 @@ function sortAndNormalize( preview, quote, sticker, + poll, // This is not in the backup // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -176,6 +177,18 @@ function sortAndNormalize( data: omit(sticker.data, 'downloadPath'), } : undefined, + poll: poll + ? { + ...poll, + votes: poll.votes?.map(vote => ({ + ...vote, + fromConversationId: mapConvoId(vote.fromConversationId), + sendStateByConversationId: mapSendState( + vote.sendStateByConversationId + ), + })), + } + : undefined, // Not an original property, but useful isUnsupported: isUnsupportedMessage(message), diff --git a/ts/test-electron/backup/integration_test.preload.ts b/ts/test-electron/backup/integration_test.preload.ts index 15cd9455bc..ef9f22e52c 100644 --- a/ts/test-electron/backup/integration_test.preload.ts +++ b/ts/test-electron/backup/integration_test.preload.ts @@ -81,7 +81,9 @@ describe('backup/integration', () => { if ( expectedString.includes('ReleaseChannelDonationRequest') || // TODO (DESKTOP-8025) roundtrip these frames - fullPath.includes('chat_folder') + fullPath.includes('chat_folder') || + // TODO (DESKTOP-9209) roundtrip these frames when feature is added + fullPath.includes('poll_terminate') ) { // Skip the unsupported tests return; diff --git a/ts/util/makeQuote.preload.ts b/ts/util/makeQuote.preload.ts index 5d355ec38d..b9bc3b45e2 100644 --- a/ts/util/makeQuote.preload.ts +++ b/ts/util/makeQuote.preload.ts @@ -55,6 +55,7 @@ export async function makeQuote( id: quoteId, isViewOnce: isTapToView(quotedMessage), isGiftBadge: isGiftBadge(quotedMessage), + isPoll: quotedMessage.poll != null, messageId, referencedMessageNotFound: false, text: getQuoteBodyText({