mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Add backup support for polls
Co-authored-by: yash-signal <yash@signal.org>
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
ts/model-types.d.ts
vendored
1
ts/model-types.d.ts
vendored
@@ -103,6 +103,7 @@ export type QuotedMessageType = {
|
||||
// from backup
|
||||
id: number | null;
|
||||
isGiftBadge?: boolean;
|
||||
isPoll?: boolean;
|
||||
isViewOnce: boolean;
|
||||
referencedMessageNotFound: boolean;
|
||||
text?: string;
|
||||
|
||||
@@ -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<string, number>();
|
||||
|
||||
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) {
|
||||
|
||||
@@ -180,6 +180,8 @@ type ChatItemParseResult = {
|
||||
additionalMessages: Array<Partial<MessageAttributesType>>;
|
||||
};
|
||||
|
||||
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<number>;
|
||||
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<ChatItemParseResult | undefined> {
|
||||
): Promise<ChatItemParseResult | undefined | typeof SKIP> {
|
||||
const { timestamp } = options;
|
||||
const logId = `fromChatItemToNonBubble(${timestamp})`;
|
||||
|
||||
@@ -2477,7 +2541,7 @@ export class BackupImportStream extends Writable {
|
||||
conversation: ConversationAttributesType;
|
||||
timestamp: number;
|
||||
}
|
||||
): Promise<ChatItemParseResult | undefined> {
|
||||
): Promise<ChatItemParseResult | undefined | typeof SKIP> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user