mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 10:19:08 +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
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
with:
|
with:
|
||||||
repository: 'signalapp/Signal-Message-Backup-Tests'
|
repository: 'signalapp/Signal-Message-Backup-Tests'
|
||||||
ref: 'ae41153c5ac776b138b778b82fa593be23b3a14c'
|
ref: '455fbe5854bd3be5002f17ae929a898c0975adc4'
|
||||||
path: 'backup-integration-tests'
|
path: 'backup-integration-tests'
|
||||||
|
|
||||||
- run: xvfb-run --auto-servernum pnpm run test-electron
|
- run: xvfb-run --auto-servernum pnpm run test-electron
|
||||||
|
|||||||
@@ -428,6 +428,7 @@ message ChatItem {
|
|||||||
GiftBadge giftBadge = 17;
|
GiftBadge giftBadge = 17;
|
||||||
ViewOnceMessage viewOnceMessage = 18;
|
ViewOnceMessage viewOnceMessage = 18;
|
||||||
DirectStoryReplyMessage directStoryReplyMessage = 19; // group story reply messages are not backed up
|
DirectStoryReplyMessage directStoryReplyMessage = 19; // group story reply messages are not backed up
|
||||||
|
Poll poll = 20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,6 +761,7 @@ message Quote {
|
|||||||
NORMAL = 1;
|
NORMAL = 1;
|
||||||
GIFT_BADGE = 2;
|
GIFT_BADGE = 2;
|
||||||
VIEW_ONCE = 3;
|
VIEW_ONCE = 3;
|
||||||
|
POLL = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message QuotedAttachment {
|
message QuotedAttachment {
|
||||||
@@ -806,6 +808,31 @@ message Reaction {
|
|||||||
uint64 sortOrder = 4;
|
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 {
|
message ChatUpdateMessage {
|
||||||
// If unset, importers should ignore the update message without throwing an error.
|
// If unset, importers should ignore the update message without throwing an error.
|
||||||
oneof update {
|
oneof update {
|
||||||
@@ -818,6 +845,7 @@ message ChatUpdateMessage {
|
|||||||
IndividualCall individualCall = 7;
|
IndividualCall individualCall = 7;
|
||||||
GroupCall groupCall = 8;
|
GroupCall groupCall = 8;
|
||||||
LearnedProfileChatUpdate learnedProfileChange = 9;
|
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
|
// from backup
|
||||||
id: number | null;
|
id: number | null;
|
||||||
isGiftBadge?: boolean;
|
isGiftBadge?: boolean;
|
||||||
|
isPoll?: boolean;
|
||||||
isViewOnce: boolean;
|
isViewOnce: boolean;
|
||||||
referencedMessageNotFound: boolean;
|
referencedMessageNotFound: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|||||||
@@ -1499,6 +1499,67 @@ export class BackupExportStream extends Readable {
|
|||||||
});
|
});
|
||||||
|
|
||||||
result.revisions = await this.#toChatItemRevisions(result, message);
|
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 {
|
} else {
|
||||||
result.standardMessage = await this.#toStandardMessage({
|
result.standardMessage = await this.#toStandardMessage({
|
||||||
message,
|
message,
|
||||||
@@ -2451,6 +2512,8 @@ export class BackupExportStream extends Readable {
|
|||||||
quoteType = Backups.Quote.Type.GIFT_BADGE;
|
quoteType = Backups.Quote.Type.GIFT_BADGE;
|
||||||
} else if (quote.isViewOnce) {
|
} else if (quote.isViewOnce) {
|
||||||
quoteType = Backups.Quote.Type.VIEW_ONCE;
|
quoteType = Backups.Quote.Type.VIEW_ONCE;
|
||||||
|
} else if (quote.isPoll) {
|
||||||
|
quoteType = Backups.Quote.Type.POLL;
|
||||||
} else {
|
} else {
|
||||||
quoteType = Backups.Quote.Type.NORMAL;
|
quoteType = Backups.Quote.Type.NORMAL;
|
||||||
if (quote.text == null && quote.attachments.length === 0) {
|
if (quote.text == null && quote.attachments.length === 0) {
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ type ChatItemParseResult = {
|
|||||||
additionalMessages: Array<Partial<MessageAttributesType>>;
|
additionalMessages: Array<Partial<MessageAttributesType>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SKIP = 'SKIP' as const;
|
||||||
|
|
||||||
function phoneToContactFormType(
|
function phoneToContactFormType(
|
||||||
type: Backups.ContactAttachment.Phone.Type | null | undefined
|
type: Backups.ContactAttachment.Phone.Type | null | undefined
|
||||||
): ContactFormType {
|
): ContactFormType {
|
||||||
@@ -1564,6 +1566,63 @@ export class BackupImportStream extends Writable {
|
|||||||
storyAuthorAci
|
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 {
|
} else {
|
||||||
const result = await this.#fromNonBubbleChatItem(item, {
|
const result = await this.#fromNonBubbleChatItem(item, {
|
||||||
aboutMe,
|
aboutMe,
|
||||||
@@ -1572,6 +1631,10 @@ export class BackupImportStream extends Writable {
|
|||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result === SKIP) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error(`${logId}: fromNonBubbleChat item returned nothing!`);
|
throw new Error(`${logId}: fromNonBubbleChat item returned nothing!`);
|
||||||
}
|
}
|
||||||
@@ -2159,6 +2222,7 @@ export class BackupImportStream extends Writable {
|
|||||||
text: dropNull(quote.text?.body),
|
text: dropNull(quote.text?.body),
|
||||||
bodyRanges: this.#fromBodyRanges(quote.text),
|
bodyRanges: this.#fromBodyRanges(quote.text),
|
||||||
isGiftBadge: quote.type === Backups.Quote.Type.GIFT_BADGE,
|
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,
|
isViewOnce: quote.type === Backups.Quote.Type.VIEW_ONCE,
|
||||||
attachments:
|
attachments:
|
||||||
quote.attachments?.map(quotedAttachment => {
|
quote.attachments?.map(quotedAttachment => {
|
||||||
@@ -2242,7 +2306,7 @@ export class BackupImportStream extends Writable {
|
|||||||
conversation: ConversationAttributesType;
|
conversation: ConversationAttributesType;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
): Promise<ChatItemParseResult | undefined> {
|
): Promise<ChatItemParseResult | undefined | typeof SKIP> {
|
||||||
const { timestamp } = options;
|
const { timestamp } = options;
|
||||||
const logId = `fromChatItemToNonBubble(${timestamp})`;
|
const logId = `fromChatItemToNonBubble(${timestamp})`;
|
||||||
|
|
||||||
@@ -2477,7 +2541,7 @@ export class BackupImportStream extends Writable {
|
|||||||
conversation: ConversationAttributesType;
|
conversation: ConversationAttributesType;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
): Promise<ChatItemParseResult | undefined> {
|
): Promise<ChatItemParseResult | undefined | typeof SKIP> {
|
||||||
const { aboutMe, author, conversation } = options;
|
const { aboutMe, author, conversation } = options;
|
||||||
|
|
||||||
if (updateMessage.groupChange) {
|
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;
|
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,
|
preview,
|
||||||
quote,
|
quote,
|
||||||
sticker,
|
sticker,
|
||||||
|
poll,
|
||||||
|
|
||||||
// This is not in the backup
|
// This is not in the backup
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
@@ -176,6 +177,18 @@ function sortAndNormalize(
|
|||||||
data: omit(sticker.data, 'downloadPath'),
|
data: omit(sticker.data, 'downloadPath'),
|
||||||
}
|
}
|
||||||
: undefined,
|
: 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
|
// Not an original property, but useful
|
||||||
isUnsupported: isUnsupportedMessage(message),
|
isUnsupported: isUnsupportedMessage(message),
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ describe('backup/integration', () => {
|
|||||||
if (
|
if (
|
||||||
expectedString.includes('ReleaseChannelDonationRequest') ||
|
expectedString.includes('ReleaseChannelDonationRequest') ||
|
||||||
// TODO (DESKTOP-8025) roundtrip these frames
|
// 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
|
// Skip the unsupported tests
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export async function makeQuote(
|
|||||||
id: quoteId,
|
id: quoteId,
|
||||||
isViewOnce: isTapToView(quotedMessage),
|
isViewOnce: isTapToView(quotedMessage),
|
||||||
isGiftBadge: isGiftBadge(quotedMessage),
|
isGiftBadge: isGiftBadge(quotedMessage),
|
||||||
|
isPoll: quotedMessage.poll != null,
|
||||||
messageId,
|
messageId,
|
||||||
referencedMessageNotFound: false,
|
referencedMessageNotFound: false,
|
||||||
text: getQuoteBodyText({
|
text: getQuoteBodyText({
|
||||||
|
|||||||
Reference in New Issue
Block a user