0 ? (optionVotes / totalVotes) * 100 : 0;
- const weVotedForThis = (pollVoteEntries ?? []).some(
- vote => vote.isMe && vote.optionIndexes.includes(index)
- );
+ const weVotedForThis = (pollVoteEntries ?? []).some(v => v.isMe);
return (
// eslint-disable-next-line react/no-array-index-key
@@ -146,8 +180,9 @@ export function PollMessageContents({
{}}
+ onCheckedChange={next =>
+ handlePollOptionClicked(index, Boolean(next))
+ }
isIncoming={isIncoming}
/>
diff --git a/ts/jobs/conversationJobQueue.preload.ts b/ts/jobs/conversationJobQueue.preload.ts
index 08fcab46a5..9d6ad67d95 100644
--- a/ts/jobs/conversationJobQueue.preload.ts
+++ b/ts/jobs/conversationJobQueue.preload.ts
@@ -20,6 +20,7 @@ import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone.preload.j
import { sendDeleteStoryForEveryone } from './helpers/sendDeleteStoryForEveryone.preload.js';
import { sendProfileKey } from './helpers/sendProfileKey.preload.js';
import { sendReaction } from './helpers/sendReaction.preload.js';
+import { sendPollVote } from './helpers/sendPollVote.preload.js';
import { sendStory } from './helpers/sendStory.preload.js';
import { sendReceipts } from './helpers/sendReceipts.preload.js';
@@ -72,6 +73,7 @@ export const conversationQueueJobEnum = z.enum([
'ProfileKey',
'ProfileKeyForCall',
'Reaction',
+ 'PollVote',
'ResendRequest',
'SavedProto',
'SenderKeyDistribution',
@@ -194,6 +196,16 @@ const reactionJobDataSchema = z.object({
});
export type ReactionJobData = z.infer
;
+const pollVoteJobDataSchema = z.object({
+ type: z.literal(conversationQueueJobEnum.enum.PollVote),
+ conversationId: z.string(),
+ pollMessageId: z.string(),
+ targetAuthorAci: aciSchema,
+ targetTimestamp: z.number(),
+ revision: z.number().optional(),
+});
+export type PollVoteJobData = z.infer;
+
const resendRequestJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.ResendRequest),
conversationId: z.string(),
@@ -258,6 +270,7 @@ export const conversationQueueJobDataSchema = z.union([
nullMessageJobDataSchema,
profileKeyJobDataSchema,
reactionJobDataSchema,
+ pollVoteJobDataSchema,
resendRequestJobDataSchema,
savedProtoJobDataSchema,
senderKeyDistributionJobDataSchema,
@@ -314,6 +327,9 @@ function shouldSendShowCaptcha(type: ConversationQueueJobEnum): boolean {
if (type === 'Reaction') {
return false;
}
+ if (type === 'PollVote') {
+ return false;
+ }
if (type === 'Receipts') {
return false;
}
@@ -958,6 +974,9 @@ export class ConversationJobQueue extends JobQueue {
case jobSet.Reaction:
await sendReaction(conversation, jobBundle, data);
break;
+ case jobSet.PollVote:
+ await sendPollVote(conversation, jobBundle, data);
+ break;
case jobSet.ResendRequest:
await sendResendRequest(conversation, jobBundle, data);
break;
diff --git a/ts/jobs/helpers/sendPollVote.preload.ts b/ts/jobs/helpers/sendPollVote.preload.ts
new file mode 100644
index 0000000000..030fc3917d
--- /dev/null
+++ b/ts/jobs/helpers/sendPollVote.preload.ts
@@ -0,0 +1,403 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { ContentHint } from '@signalapp/libsignal-client';
+import * as Errors from '../../types/errors.std.js';
+import { isGroupV2, isMe } from '../../util/whatTypeOfConversation.dom.js';
+import { getSendOptions } from '../../util/getSendOptions.preload.js';
+import { handleMessageSend } from '../../util/handleMessageSend.preload.js';
+import { sendContentMessageToGroup } from '../../util/sendToGroup.preload.js';
+import { MessageModel } from '../../models/messages.preload.js';
+import { generateMessageId } from '../../util/generateMessageId.node.js';
+import { incrementMessageCounter } from '../../util/incrementMessageCounter.preload.js';
+import { ourProfileKeyService } from '../../services/ourProfileKey.std.js';
+import { send, sendSyncMessageOnly } from '../../messages/send.preload.js';
+import { handleMultipleSendErrors } from './handleMultipleSendErrors.std.js';
+import { getMessageById } from '../../messages/getMessageById.preload.js';
+import {
+ isSent,
+ SendStatus,
+ type SendStateByConversationId,
+} from '../../messages/MessageSendState.std.js';
+import type { ServiceIdString } from '../../types/ServiceId.std.js';
+import type { LoggerType } from '../../types/Logging.std.js';
+import type { MessagePollVoteType } from '../../types/Polls.dom.js';
+import type { ConversationModel } from '../../models/conversations.preload.js';
+import type {
+ ConversationQueueJobBundle,
+ PollVoteJobData,
+} from '../conversationJobQueue.preload.js';
+import * as pollVoteUtil from '../../polls/util.std.js';
+import { strictAssert } from '../../util/assert.std.js';
+
+export async function sendPollVote(
+ conversation: ConversationModel,
+ {
+ isFinalAttempt,
+ messaging,
+ shouldContinue,
+ timeRemaining,
+ log: jobLog,
+ }: ConversationQueueJobBundle,
+ data: PollVoteJobData
+): Promise {
+ const { pollMessageId, revision } = data;
+
+ await window.ConversationController.load();
+
+ const pollMessage = await getMessageById(pollMessageId);
+ if (!pollMessage) {
+ jobLog.info(
+ `poll message ${pollMessageId} was not found, maybe because it was deleted. Giving up on sending poll vote`
+ );
+ return;
+ }
+
+ if (!isGroupV2(conversation.attributes)) {
+ jobLog.error('sendPollVote: Non-group conversation; aborting');
+ return;
+ }
+ let sendErrors: Array = [];
+ const saveErrors = (errors: Array): void => {
+ sendErrors = errors;
+ };
+
+ let originalError: Error | undefined;
+ let pendingVote: MessagePollVoteType | undefined;
+
+ try {
+ const pollMessageConversation = window.ConversationController.get(
+ pollMessage.get('conversationId')
+ );
+ if (pollMessageConversation !== conversation) {
+ jobLog.error(
+ `poll message conversation '${pollMessageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
+ );
+ return;
+ }
+
+ // Find our pending vote
+ const ourConversationId =
+ window.ConversationController.getOurConversationIdOrThrow();
+ const pollDataOnMessage = pollMessage.get('poll');
+ if (!pollDataOnMessage) {
+ jobLog.error('sendPollVote: poll message has no poll data');
+ return;
+ }
+
+ pendingVote = pollDataOnMessage.votes?.find(
+ vote =>
+ vote.fromConversationId === ourConversationId &&
+ vote.sendStateByConversationId != null
+ );
+
+ if (!pendingVote) {
+ jobLog.info('sendPollVote: no pending vote found, nothing to send');
+ return;
+ }
+
+ const currentPendingVote = pendingVote;
+
+ if (!shouldContinue) {
+ jobLog.info('sendPollVote: ran out of time; giving up');
+ const pollField = pollMessage.get('poll');
+ if (pollField?.votes) {
+ const updatedVotes = pollVoteUtil.markOutgoingPollVoteFailed(
+ pollField.votes,
+ currentPendingVote
+ );
+ pollMessage.set({
+ poll: {
+ ...pollField,
+ votes: updatedVotes,
+ },
+ });
+ }
+ await window.MessageCache.saveMessage(pollMessage.attributes);
+ return;
+ }
+
+ // Use current vote data, not stale job data
+ const currentVoteCount = currentPendingVote.voteCount;
+ const currentOptionIndexes = [...currentPendingVote.optionIndexes];
+ const currentTimestamp = currentPendingVote.timestamp;
+
+ const { recipientServiceIdsWithoutMe, untrustedServiceIds } = getRecipients(
+ jobLog,
+ currentPendingVote,
+ conversation
+ );
+
+ if (untrustedServiceIds.length) {
+ window.reduxActions.conversations.conversationStoppedByMissingVerification(
+ {
+ conversationId: conversation.id,
+ untrustedServiceIds,
+ }
+ );
+ throw new Error(
+ `Poll vote for message ${pollMessageId} sending blocked because ${untrustedServiceIds.length} conversation(s) were untrusted. Failing this attempt.`
+ );
+ }
+
+ const expireTimer = pollMessageConversation.get('expireTimer');
+ const profileKey = conversation.get('profileSharing')
+ ? await ourProfileKeyService.get()
+ : undefined;
+
+ const unsentConversationIds = Array.from(
+ pollVoteUtil.getUnsentConversationIds(currentPendingVote)
+ );
+ const ephemeral = new MessageModel({
+ ...generateMessageId(incrementMessageCounter()),
+ type: 'outgoing',
+ conversationId: conversation.id,
+ sent_at: currentTimestamp,
+ received_at_ms: currentTimestamp,
+ timestamp: currentTimestamp,
+ sendStateByConversationId: Object.fromEntries(
+ unsentConversationIds.map(id => [
+ id,
+ {
+ status: SendStatus.Pending,
+ updatedAt: Date.now(),
+ },
+ ])
+ ),
+ });
+ ephemeral.doNotSave = true;
+ window.MessageCache.register(ephemeral);
+
+ let didFullySend: boolean;
+ let ephemeralSendStateByConversationId: SendStateByConversationId = {};
+
+ if (recipientServiceIdsWithoutMe.length === 0) {
+ jobLog.info('sending sync poll vote message only');
+ const groupV2Info = conversation.getGroupV2Info({
+ members: recipientServiceIdsWithoutMe,
+ });
+ if (!groupV2Info) {
+ jobLog.error(
+ 'sendPollVote: Missing groupV2Info for group conversation'
+ );
+ return;
+ }
+
+ const dataMessage = await messaging.getPollVoteDataMessage({
+ expireTimer,
+ expireTimerVersion: conversation.getExpireTimerVersion(),
+ groupV2: groupV2Info,
+ profileKey,
+ pollVote: {
+ targetAuthorAci: data.targetAuthorAci,
+ targetTimestamp: data.targetTimestamp,
+ optionIndexes: currentOptionIndexes,
+ voteCount: currentVoteCount,
+ },
+ timestamp: currentTimestamp,
+ });
+
+ await sendSyncMessageOnly(ephemeral, {
+ dataMessage,
+ saveErrors,
+ targetTimestamp: currentTimestamp,
+ });
+
+ didFullySend = true;
+ } else {
+ const sendOptions = await getSendOptions(conversation.attributes);
+
+ const promise = conversation.queueJob(
+ 'conversationQueue/sendPollVote',
+ async abortSignal => {
+ const groupV2Info = conversation.getGroupV2Info({
+ members: recipientServiceIdsWithoutMe,
+ });
+ if (groupV2Info && revision != null) {
+ groupV2Info.revision = revision;
+ }
+
+ strictAssert(
+ groupV2Info,
+ 'could not get group info from conversation'
+ );
+
+ const contentMessage = await messaging.getPollVoteContentMessage({
+ groupV2: groupV2Info,
+ timestamp: currentTimestamp,
+ profileKey,
+ expireTimer,
+ expireTimerVersion: conversation.getExpireTimerVersion(),
+ pollVote: {
+ targetAuthorAci: data.targetAuthorAci,
+ targetTimestamp: data.targetTimestamp,
+ optionIndexes: currentOptionIndexes,
+ voteCount: currentVoteCount,
+ },
+ });
+
+ if (abortSignal?.aborted) {
+ throw new Error('sendPollVote was aborted');
+ }
+
+ return sendContentMessageToGroup({
+ contentHint: ContentHint.Resendable,
+ contentMessage,
+ messageId: pollMessageId,
+ recipients: recipientServiceIdsWithoutMe,
+ sendOptions,
+ sendTarget: conversation.toSenderKeyTarget(),
+ sendType: 'pollVote',
+ timestamp: currentTimestamp,
+ urgent: true,
+ });
+ }
+ );
+
+ await send(ephemeral, {
+ promise: handleMessageSend(promise, {
+ messageIds: [pollMessageId],
+ sendType: 'pollVote',
+ }),
+ saveErrors,
+ targetTimestamp: currentTimestamp,
+ });
+
+ // Await the inner promise to get SendMessageProtoError for upstream processors
+ try {
+ await promise;
+ } catch (error) {
+ if (error instanceof Error) {
+ originalError = error;
+ } else {
+ jobLog.error(
+ `promise threw something other than an error: ${Errors.toLogFormat(
+ error
+ )}`
+ );
+ }
+ }
+
+ // Check if the send fully succeeded
+ ephemeralSendStateByConversationId =
+ ephemeral.get('sendStateByConversationId') || {};
+
+ didFullySend = Object.values(ephemeralSendStateByConversationId).every(
+ sendState => isSent(sendState.status)
+ );
+ }
+
+ // Sync the ephemeral's send states back to the poll vote
+ const updatedPoll = pollMessage.get('poll');
+ if (updatedPoll?.votes) {
+ const updatedVotes = pollVoteUtil.markOutgoingPollVoteSent(
+ updatedPoll.votes,
+ currentPendingVote,
+ ephemeralSendStateByConversationId
+ );
+ pollMessage.set({
+ poll: {
+ ...updatedPoll,
+ votes: updatedVotes,
+ },
+ });
+ }
+
+ if (!didFullySend) {
+ throw new Error('poll vote did not fully send');
+ }
+ } catch (thrownError: unknown) {
+ await handleMultipleSendErrors({
+ errors: [thrownError, ...sendErrors],
+ isFinalAttempt,
+ log: jobLog,
+ markFailed: () => {
+ jobLog.info('poll vote send failed');
+ const updatedPoll = pollMessage.get('poll');
+ if (updatedPoll?.votes && pendingVote) {
+ const updatedVotes = pollVoteUtil.markOutgoingPollVoteFailed(
+ updatedPoll.votes,
+ pendingVote
+ );
+ pollMessage.set({
+ poll: {
+ ...updatedPoll,
+ votes: updatedVotes,
+ },
+ });
+ }
+ },
+ timeRemaining,
+ toThrow: originalError || thrownError,
+ });
+ } finally {
+ await window.MessageCache.saveMessage(pollMessage.attributes);
+ }
+}
+
+function getRecipients(
+ log: LoggerType,
+ pendingVote: MessagePollVoteType,
+ conversation: ConversationModel
+): {
+ allRecipientServiceIds: Array;
+ recipientServiceIdsWithoutMe: Array;
+ untrustedServiceIds: Array;
+} {
+ const allRecipientServiceIds: Array = [];
+ const recipientServiceIdsWithoutMe: Array = [];
+ const untrustedServiceIds: Array = [];
+
+ const currentConversationRecipients = conversation.getMemberConversationIds();
+
+ // Only send to recipients who haven't received this vote yet
+ for (const conversationId of pollVoteUtil.getUnsentConversationIds(
+ pendingVote
+ )) {
+ const recipient = window.ConversationController.get(conversationId);
+ if (!recipient) {
+ continue;
+ }
+
+ const recipientIdentifier = recipient.getSendTarget();
+ const isRecipientMe = isMe(recipient.attributes);
+
+ if (
+ !recipientIdentifier ||
+ (!currentConversationRecipients.has(conversationId) && !isRecipientMe)
+ ) {
+ continue;
+ }
+
+ if (recipient.isUntrusted()) {
+ const serviceId = recipient.getServiceId();
+ if (!serviceId) {
+ log.error(
+ `sendPollVote/getRecipients: Recipient ${recipient.idForLogging()} is untrusted but has no serviceId`
+ );
+ continue;
+ }
+ untrustedServiceIds.push(serviceId);
+ continue;
+ }
+
+ if (recipient.isUnregistered()) {
+ continue;
+ }
+
+ if (recipient.isBlocked()) {
+ continue;
+ }
+
+ allRecipientServiceIds.push(recipientIdentifier);
+ if (!isRecipientMe) {
+ recipientServiceIdsWithoutMe.push(recipientIdentifier);
+ }
+ }
+
+ return {
+ allRecipientServiceIds,
+ recipientServiceIdsWithoutMe,
+ untrustedServiceIds,
+ };
+}
diff --git a/ts/messageModifiers/Polls.preload.ts b/ts/messageModifiers/Polls.preload.ts
index c9df6ccb6b..cbb14c0b6c 100644
--- a/ts/messageModifiers/Polls.preload.ts
+++ b/ts/messageModifiers/Polls.preload.ts
@@ -14,7 +14,7 @@ import { createLogger } from '../logging/log.std.js';
import { isIncoming, isOutgoing } from '../messages/helpers.std.js';
import { getAuthor } from '../messages/sources.preload.js';
-import { isSent } from '../messages/MessageSendState.std.js';
+import { isSent, SendStatus } from '../messages/MessageSendState.std.js';
import { getPropForTimestamp } from '../util/editHelpers.std.js';
import { isMe } from '../util/whatTypeOfConversation.dom.js';
@@ -383,11 +383,24 @@ export async function handlePollVote(
'Vote can only be from this device, from sync, or from someone else'
);
+ const ourConversationId =
+ window.ConversationController.getOurConversationIdOrThrow();
+
const newVote: MessagePollVoteType = {
fromConversationId: vote.fromConversationId,
optionIndexes: vote.optionIndexes,
voteCount: vote.voteCount,
timestamp: vote.timestamp,
+ sendStateByConversationId: isFromThisDevice
+ ? Object.fromEntries(
+ Array.from(conversation.getMemberConversationIds())
+ .filter(id => id !== ourConversationId)
+ .map(id => [
+ id,
+ { status: SendStatus.Pending, updatedAt: Date.now() },
+ ])
+ )
+ : undefined,
};
// Update or add vote with conflict resolution
@@ -396,24 +409,50 @@ export async function handlePollVote(
: [];
let updatedVotes: Array;
- const existingVoteIndex = currentVotes.findIndex(
- v => v.fromConversationId === vote.fromConversationId
- );
+ if (isFromThisDevice) {
+ // For votes from this device: keep sent votes, remove pending votes, add new vote
+ // This matches reaction behavior where we can have one sent + one pending
+ const pendingVotesFromUs = currentVotes.filter(
+ v =>
+ v.fromConversationId === vote.fromConversationId &&
+ v.sendStateByConversationId != null
+ );
- if (existingVoteIndex !== -1) {
- const existingVote = currentVotes[existingVoteIndex];
-
- if (newVote.voteCount > existingVote.voteCount) {
- updatedVotes = [...currentVotes];
- updatedVotes[existingVoteIndex] = newVote;
- } else {
- log.info(
- 'handlePollVote: Keeping existing vote with higher or same voteCount'
- );
- updatedVotes = currentVotes;
- }
+ updatedVotes = [
+ ...currentVotes.filter(v => !pendingVotesFromUs.includes(v)),
+ newVote,
+ ];
} else {
- updatedVotes = [...currentVotes, newVote];
+ // For sync/others: use voteCount-based conflict resolution
+ const existingVoteIndex = currentVotes.findIndex(
+ v => v.fromConversationId === vote.fromConversationId
+ );
+
+ if (existingVoteIndex !== -1) {
+ const existingVote = currentVotes[existingVoteIndex];
+
+ if (newVote.voteCount > existingVote.voteCount) {
+ updatedVotes = [...currentVotes];
+ updatedVotes[existingVoteIndex] = newVote;
+ } else if (
+ isFromSync &&
+ newVote.voteCount === existingVote.voteCount &&
+ newVote.timestamp > existingVote.timestamp
+ ) {
+ log.info(
+ 'handlePollVote: Same voteCount from sync, using timestamp tiebreaker'
+ );
+ updatedVotes = [...currentVotes];
+ updatedVotes[existingVoteIndex] = newVote;
+ } else {
+ log.info(
+ 'handlePollVote: Keeping existing vote with higher or same voteCount'
+ );
+ updatedVotes = currentVotes;
+ }
+ } else {
+ updatedVotes = [...currentVotes, newVote];
+ }
}
message.set({
diff --git a/ts/polls/enqueuePollVoteForSend.preload.ts b/ts/polls/enqueuePollVoteForSend.preload.ts
new file mode 100644
index 0000000000..a6aba65041
--- /dev/null
+++ b/ts/polls/enqueuePollVoteForSend.preload.ts
@@ -0,0 +1,97 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { v7 as generateUuid } from 'uuid';
+import {
+ conversationJobQueue,
+ conversationQueueJobEnum,
+} from '../jobs/conversationJobQueue.preload.js';
+import { getMessageById } from '../messages/getMessageById.preload.js';
+import {
+ handlePollVote,
+ PollSource,
+} from '../messageModifiers/Polls.preload.js';
+import type { PollVoteAttributesType } from '../messageModifiers/Polls.preload.js';
+import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp.std.js';
+import { getSourceServiceId } from '../messages/sources.preload.js';
+import { isAciString } from '../util/isAciString.std.js';
+import { isGroup } from '../util/whatTypeOfConversation.dom.js';
+import { strictAssert } from '../util/assert.std.js';
+import { createLogger } from '../logging/log.std.js';
+
+const log = createLogger('enqueuePollVoteForSend');
+
+export async function enqueuePollVoteForSend({
+ messageId,
+ optionIndexes,
+}: Readonly<{
+ messageId: string;
+ optionIndexes: ReadonlyArray;
+}>): Promise {
+ const message = await getMessageById(messageId);
+ strictAssert(message, 'enqueuePollVoteForSend: no message found');
+
+ const conversation = window.ConversationController.get(
+ message.get('conversationId')
+ );
+ strictAssert(
+ conversation,
+ 'enqueuePollVoteForSend: No conversation extracted from target message'
+ );
+ strictAssert(
+ isGroup(conversation.attributes),
+ 'enqueuePollVoteForSend: conversation must be a group'
+ );
+
+ const timestamp = Date.now();
+ const targetAuthorAci = getSourceServiceId(message.attributes);
+ strictAssert(targetAuthorAci, 'no author service ID');
+ strictAssert(isAciString(targetAuthorAci), 'author must be ACI');
+ const targetTimestamp = getMessageSentTimestamp(message.attributes, { log });
+ strictAssert(targetTimestamp, 'no target timestamp');
+
+ // Compute next voteCount for our ACI
+ const ourId = window.ConversationController.getOurConversationIdOrThrow();
+ const poll = message.get('poll');
+ let nextVoteCount = 1;
+ if (poll?.votes && poll.votes.length > 0) {
+ const mine = poll.votes.filter(v => v.fromConversationId === ourId);
+ if (mine.length > 0) {
+ const maxCount = Math.max(...mine.map(v => v.voteCount || 0));
+ nextVoteCount = maxCount + 1;
+ }
+ }
+
+ // Update local state immediately
+ const vote: PollVoteAttributesType = {
+ envelopeId: generateUuid(),
+ removeFromMessageReceiverCache: () => undefined,
+ fromConversationId: ourId,
+ source: PollSource.FromThisDevice,
+ targetAuthorAci,
+ targetTimestamp,
+ optionIndexes: [...optionIndexes],
+ voteCount: nextVoteCount,
+ receivedAtDate: timestamp,
+ timestamp,
+ };
+
+ await handlePollVote(message, vote, { shouldPersist: true });
+
+ // Queue the send job
+ await conversationJobQueue.add(
+ {
+ type: conversationQueueJobEnum.enum.PollVote,
+ conversationId: conversation.id,
+ pollMessageId: messageId,
+ targetAuthorAci,
+ targetTimestamp,
+ revision: conversation.get('revision'),
+ },
+ async jobToInsert => {
+ await window.MessageCache.saveMessage(message.attributes, {
+ jobToInsert,
+ });
+ }
+ );
+}
diff --git a/ts/polls/util.std.ts b/ts/polls/util.std.ts
new file mode 100644
index 0000000000..2218d740f2
--- /dev/null
+++ b/ts/polls/util.std.ts
@@ -0,0 +1,109 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { omit } from 'lodash';
+import type { MessagePollVoteType } from '../types/Polls.dom.js';
+import { isSent } from '../messages/MessageSendState.std.js';
+import type { SendStateByConversationId } from '../messages/MessageSendState.std.js';
+
+export function* getUnsentConversationIds(
+ pollVote: Readonly>
+): Iterable {
+ const { sendStateByConversationId = {} } = pollVote;
+ for (const [id, sendState] of Object.entries(sendStateByConversationId)) {
+ if (!isSent(sendState.status)) {
+ yield id;
+ }
+ }
+}
+
+export function isOutgoingPollVoteCompletelyUnsent(
+ pollVote: Readonly>
+): boolean {
+ if (!pollVote.sendStateByConversationId) {
+ return false;
+ }
+ return Object.values(pollVote.sendStateByConversationId).every(
+ sendState => !isSent(sendState.status)
+ );
+}
+
+/**
+ * Updates the poll vote's sendStateByConversationId based on the ephemeral message's
+ * send states after a send attempt.
+ *
+ * This syncs the full SendState objects (status, updatedAt) from the ephemeral message
+ * back to the poll vote in the poll.votes[] array.
+ */
+export function markOutgoingPollVoteSent(
+ allVotes: ReadonlyArray,
+ targetVote: Readonly,
+ ephemeralSendStateByConversationId: SendStateByConversationId
+): Array {
+ const result: Array = [];
+
+ const mergedSendStateByConversationId: SendStateByConversationId = {
+ ...(targetVote.sendStateByConversationId || {}),
+ ...ephemeralSendStateByConversationId,
+ };
+
+ const isFullySent = Object.values(mergedSendStateByConversationId).every(
+ sendState => isSent(sendState.status)
+ );
+
+ for (const vote of allVotes) {
+ const isTargetVote =
+ vote.fromConversationId === targetVote.fromConversationId &&
+ vote.voteCount === targetVote.voteCount;
+
+ if (isTargetVote) {
+ if (isFullySent) {
+ result.push(omit(vote, ['sendStateByConversationId']));
+ } else {
+ result.push({
+ ...vote,
+ sendStateByConversationId: mergedSendStateByConversationId,
+ });
+ }
+ } else {
+ // Remove older sent votes from same sender when new vote fully sends
+ const shouldKeep = !(
+ isFullySent &&
+ vote.fromConversationId === targetVote.fromConversationId &&
+ !vote.sendStateByConversationId && // finished sending so no send state
+ vote.voteCount < targetVote.voteCount
+ );
+ if (shouldKeep) {
+ result.push(vote);
+ }
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Marks a poll vote as failed - removes it if completely unsent, otherwise just
+ * removes the send state tracking.
+ */
+export function markOutgoingPollVoteFailed(
+ allVotes: ReadonlyArray,
+ targetVote: Readonly
+): Array {
+ if (isOutgoingPollVoteCompletelyUnsent(targetVote)) {
+ // Remove the vote entirely if it was never sent to anyone
+ return allVotes.filter(
+ candidateVote =>
+ candidateVote.fromConversationId !== targetVote.fromConversationId ||
+ candidateVote.voteCount !== targetVote.voteCount
+ );
+ }
+
+ // Otherwise just remove the send state tracking
+ return allVotes.map(candidateVote =>
+ candidateVote.fromConversationId === targetVote.fromConversationId &&
+ candidateVote.voteCount === targetVote.voteCount
+ ? omit(candidateVote, ['sendStateByConversationId'])
+ : candidateVote
+ );
+}
diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts
index 568091f0dc..f509818755 100644
--- a/ts/state/ducks/conversations.preload.ts
+++ b/ts/state/ducks/conversations.preload.ts
@@ -240,6 +240,7 @@ import { getCurrentChatFolders } from '../selectors/chatFolders.std.js';
import { isConversationUnread } from '../../util/isConversationUnread.std.js';
import { CurrentChatFolders } from '../../types/CurrentChatFolders.std.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
+import { enqueuePollVoteForSend as enqueuePollVoteForSendHelper } from '../../polls/enqueuePollVoteForSend.preload.js';
const {
chunk,
@@ -1258,6 +1259,7 @@ export const actions = {
saveAvatarToDisk,
scrollToMessage,
scrollToOldestUnreadMention,
+ sendPollVote,
setPendingRequestedAvatarDownload,
startAvatarDownload,
showSpoiler,
@@ -2651,6 +2653,28 @@ function retryMessageSend(
};
}
+function sendPollVote({
+ messageId,
+ optionIndexes,
+}: Readonly<{
+ messageId: string;
+ optionIndexes: ReadonlyArray;
+}>): ThunkAction {
+ return async dispatch => {
+ try {
+ await enqueuePollVoteForSendHelper({ messageId, optionIndexes });
+ } catch (error) {
+ log.error('sendPollVote: Failed to enqueue poll vote', error);
+ // TODO DESKTOP-9343: show toast on exception
+ }
+
+ dispatch({
+ type: 'NOOP',
+ payload: null,
+ });
+ };
+}
+
export function copyMessageText(
messageId: string
): ThunkAction {
diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts
index 44bb23dc4a..3a84710312 100644
--- a/ts/state/selectors/message.preload.ts
+++ b/ts/state/selectors/message.preload.ts
@@ -144,7 +144,10 @@ import { calculateExpirationTimestamp } from '../../util/expirationTimer.std.js'
import { isSignalConversation } from '../../util/isSignalConversation.dom.js';
import type { AnyPaymentEvent } from '../../types/Payment.std.js';
import { isPaymentNotificationEvent } from '../../types/Payment.std.js';
-import type { PollMessageAttribute } from '../../types/Polls.dom.js';
+import type {
+ MessagePollVoteType,
+ PollMessageAttribute,
+} from '../../types/Polls.dom.js';
import {
getTitleNoDefault,
getTitle,
@@ -522,32 +525,47 @@ const getPollForMessage = (
};
}
- const resolvedVotes: ReadonlyArray = poll.votes.map(
- vote => {
- const voter = conversationSelector(vote.fromConversationId);
-
- const from: PollVoteWithUserType['from'] = {
- acceptedMessageRequest: voter.acceptedMessageRequest,
- avatarUrl: voter.avatarUrl,
- badges: voter.badges,
- color: voter.color,
- id: voter.id,
- isMe: voter.isMe,
- name: voter.name,
- phoneNumber: voter.phoneNumber,
- profileName: voter.profileName,
- sharedGroupNames: voter.sharedGroupNames,
- title: voter.title,
- };
-
- return {
- optionIndexes: vote.optionIndexes,
- timestamp: vote.timestamp,
- isMe: voter.id === ourConversationId,
- from,
- };
+ // Deduplicate votes by sender - keep only the newest vote per sender
+ // (highest voteCount, or newest timestamp if voteCount is equal)
+ const voteByFrom = new Map();
+ for (const vote of poll.votes) {
+ const existingVote = voteByFrom.get(vote.fromConversationId);
+ if (
+ !existingVote ||
+ vote.voteCount > existingVote.voteCount ||
+ (vote.voteCount === existingVote.voteCount &&
+ vote.timestamp > existingVote.timestamp)
+ ) {
+ voteByFrom.set(vote.fromConversationId, vote);
}
- );
+ }
+
+ const resolvedVotes: ReadonlyArray = Array.from(
+ voteByFrom.values()
+ ).map(vote => {
+ const voter = conversationSelector(vote.fromConversationId);
+
+ const from: PollVoteWithUserType['from'] = {
+ acceptedMessageRequest: voter.acceptedMessageRequest,
+ avatarUrl: voter.avatarUrl,
+ badges: voter.badges,
+ color: voter.color,
+ id: voter.id,
+ isMe: voter.isMe,
+ name: voter.name,
+ phoneNumber: voter.phoneNumber,
+ profileName: voter.profileName,
+ sharedGroupNames: voter.sharedGroupNames,
+ title: voter.title,
+ };
+
+ return {
+ optionIndexes: vote.optionIndexes,
+ timestamp: vote.timestamp,
+ isMe: voter.id === ourConversationId,
+ from,
+ };
+ });
const votesByOption = new Map>();
let totalNumVotes = 0;
diff --git a/ts/state/smart/MessageDetail.preload.tsx b/ts/state/smart/MessageDetail.preload.tsx
index f5dc30c82c..047d0c1cfa 100644
--- a/ts/state/smart/MessageDetail.preload.tsx
+++ b/ts/state/smart/MessageDetail.preload.tsx
@@ -49,6 +49,7 @@ export const SmartMessageDetail = memo(
popPanelForConversation,
pushPanelForConversation,
retryMessageSend,
+ sendPollVote,
saveAttachment,
saveAttachments,
showAttachmentDownloadStillInProgressToast,
@@ -104,6 +105,7 @@ export const SmartMessageDetail = memo(
messageExpanded={messageExpanded}
openGiftBadge={openGiftBadge}
retryMessageSend={retryMessageSend}
+ sendPollVote={sendPollVote}
pushPanelForConversation={pushPanelForConversation}
receivedAt={receivedAt}
renderAudioAttachment={renderAudioAttachment}
diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx
index c4847ceb7b..e46ad0465b 100644
--- a/ts/state/smart/TimelineItem.preload.tsx
+++ b/ts/state/smart/TimelineItem.preload.tsx
@@ -134,6 +134,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
retryMessageSend,
saveAttachment,
saveAttachments,
+ sendPollVote,
setMessageToEdit,
showAttachmentDownloadStillInProgressToast,
showConversation,
@@ -227,6 +228,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
retryDeleteForEveryone={retryDeleteForEveryone}
retryMessageSend={retryMessageSend}
+ sendPollVote={sendPollVote}
returnToActiveCall={returnToActiveCall}
saveAttachment={saveAttachment}
saveAttachments={saveAttachments}
diff --git a/ts/textsecure/SendMessage.preload.ts b/ts/textsecure/SendMessage.preload.ts
index d970a6ef82..481fe964f4 100644
--- a/ts/textsecure/SendMessage.preload.ts
+++ b/ts/textsecure/SendMessage.preload.ts
@@ -101,7 +101,7 @@ import {
import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types.std.js';
import { isProtoBinaryEncodingEnabled } from '../util/isProtoBinaryEncodingEnabled.std.js';
import type { GroupSendToken } from '../types/GroupSendEndorsements.std.js';
-import type { PollCreateType } from '../types/Polls.dom.js';
+import type { OutgoingPollVote, PollCreateType } from '../types/Polls.dom.js';
import { itemStorage } from './Storage.preload.js';
import { accountManager } from './AccountManager.preload.js';
@@ -215,6 +215,7 @@ export type MessageOptionsType = {
recipients: ReadonlyArray;
sticker?: OutgoingStickerType;
reaction?: ReactionType;
+ pollVote?: OutgoingPollVote;
pollCreate?: PollCreateType;
deletedForEveryoneTimestamp?: number;
targetTimestampForEdit?: number;
@@ -240,9 +241,15 @@ export type GroupSendOptionsType = {
sticker?: OutgoingStickerType;
storyContext?: StoryContextType;
timestamp: number;
+ pollVote?: OutgoingPollVote;
pollCreate?: PollCreateType;
};
+export type PollVoteBuildOptions = Required<
+ Pick
+> &
+ Pick;
+
class Message {
attachments: ReadonlyArray;
@@ -291,6 +298,8 @@ class Message {
storyContext?: StoryContextType;
+ pollVote?: OutgoingPollVote;
+
constructor(options: MessageOptionsType) {
this.attachments = options.attachments || [];
this.body = options.body;
@@ -313,6 +322,8 @@ class Message {
this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp;
this.groupCallUpdate = options.groupCallUpdate;
this.storyContext = options.storyContext;
+ // Polls
+ this.pollVote = options.pollVote;
if (!(this.recipients instanceof Array)) {
throw new Error('Invalid recipient list');
@@ -784,6 +795,84 @@ export class MessageSender {
return Proto.DataMessage.encode(dataMessage).finish();
}
+ createDataMessageProtoForPollVote({
+ groupV2,
+ timestamp,
+ profileKey,
+ expireTimer,
+ expireTimerVersion,
+ pollVote,
+ }: PollVoteBuildOptions): Proto.DataMessage {
+ const dataMessage = new Proto.DataMessage();
+ dataMessage.timestamp = Long.fromNumber(timestamp);
+
+ const groupContext = new Proto.GroupContextV2();
+ groupContext.masterKey = groupV2.masterKey;
+ groupContext.revision = groupV2.revision;
+ dataMessage.groupV2 = groupContext;
+
+ if (typeof expireTimer !== 'undefined') {
+ dataMessage.expireTimer = expireTimer;
+ }
+ if (typeof expireTimerVersion !== 'undefined') {
+ dataMessage.expireTimerVersion = expireTimerVersion;
+ }
+ if (profileKey) {
+ dataMessage.profileKey = profileKey;
+ }
+
+ const vote = new Proto.DataMessage.PollVote();
+ vote.targetAuthorAciBinary = toAciObject(
+ pollVote.targetAuthorAci
+ ).getRawUuidBytes();
+ vote.targetSentTimestamp = Long.fromNumber(pollVote.targetTimestamp);
+ vote.optionIndexes = pollVote.optionIndexes.slice();
+ vote.voteCount = pollVote.voteCount;
+ dataMessage.pollVote = vote;
+
+ return dataMessage;
+ }
+
+ async getPollVoteDataMessage({
+ groupV2,
+ timestamp,
+ profileKey,
+ expireTimer,
+ expireTimerVersion,
+ pollVote,
+ }: PollVoteBuildOptions): Promise {
+ const proto = this.createDataMessageProtoForPollVote({
+ groupV2,
+ timestamp,
+ profileKey,
+ expireTimer,
+ expireTimerVersion,
+ pollVote,
+ });
+ return Proto.DataMessage.encode(proto).finish();
+ }
+
+ async getPollVoteContentMessage({
+ groupV2,
+ timestamp,
+ profileKey,
+ expireTimer,
+ expireTimerVersion,
+ pollVote,
+ }: PollVoteBuildOptions): Promise {
+ const dataMessage = this.createDataMessageProtoForPollVote({
+ groupV2,
+ timestamp,
+ profileKey,
+ expireTimer,
+ expireTimerVersion,
+ pollVote,
+ });
+ const contentMessage = new Proto.Content();
+ contentMessage.dataMessage = dataMessage;
+ return contentMessage;
+ }
+
async getStoryMessage({
allowsReplies,
bodyRanges,
@@ -952,6 +1041,7 @@ export class MessageSender {
storyContext,
targetTimestampForEdit,
timestamp,
+ pollVote,
pollCreate,
} = options;
@@ -996,6 +1086,7 @@ export class MessageSender {
storyContext,
targetTimestampForEdit,
timestamp,
+ pollVote,
pollCreate,
};
}
diff --git a/ts/types/Polls.dom.ts b/ts/types/Polls.dom.ts
index 7566e54c41..7613c1b68e 100644
--- a/ts/types/Polls.dom.ts
+++ b/ts/types/Polls.dom.ts
@@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
-import { isAciString } from '../util/isAciString.std.js';
import { hasAtMostGraphemes } from '../util/grapheme.std.js';
import {
Environment,
@@ -11,6 +10,8 @@ import {
} from '../environment.std.js';
import * as RemoteConfig from '../RemoteConfig.dom.js';
import { isAlpha, isBeta, isProduction } from '../util/version.std.js';
+import type { SendStateByConversationId } from '../messages/MessageSendState.std.js';
+import { aciSchema } from './ServiceId.std.js';
// PollCreate schema (processed shape)
// - question: required, 1..100 chars
@@ -43,19 +44,17 @@ export const PollCreateSchema = z
// PollVote schema (processed shape)
// - targetAuthorAci: required, non-empty ACI string
// - targetTimestamp: required, positive int
-// - optionIndexes: required, 1..10 ints in [0, 9]
+// - optionIndexes: required, 0..10 ints in [0, 9] (empty array = clearing vote)
// - voteCount: optional, int in [0, 1_000_000]
export const PollVoteSchema = z
.object({
- targetAuthorAci: z
- .string()
- .min(1)
- .refine(isAciString, 'targetAuthorAci must be a valid ACI string'),
+ targetAuthorAci: aciSchema,
targetTimestamp: z.number().int().positive(),
- optionIndexes: z.array(z.number().int().min(0).max(9)).min(1).max(10),
+ optionIndexes: z.array(z.number().int().min(0).max(9)).min(0).max(10),
voteCount: z.number().int().min(0),
})
.describe('PollVote');
+export type OutgoingPollVote = Readonly>;
// PollTerminate schema (processed shape)
// - targetTimestamp: required, positive int
@@ -70,6 +69,7 @@ export type MessagePollVoteType = {
optionIndexes: ReadonlyArray;
voteCount: number;
timestamp: number;
+ sendStateByConversationId?: SendStateByConversationId;
};
export type PollMessageAttribute = {
diff --git a/ts/util/handleMessageSend.preload.ts b/ts/util/handleMessageSend.preload.ts
index 801f367c9c..e5d383f97d 100644
--- a/ts/util/handleMessageSend.preload.ts
+++ b/ts/util/handleMessageSend.preload.ts
@@ -31,6 +31,7 @@ export const sendTypesEnum = z.enum([
'expirationTimerUpdate', // non-urgent
'groupChange', // non-urgent
'reaction',
+ 'pollVote', // non-urgent
'typing', // excluded from send log; non-urgent
// Responding to incoming messages, all non-urgent