Add ability to send poll votes

This commit is contained in:
yash-signal
2025-10-21 17:09:51 -05:00
committed by GitHub
parent 1ddb81e053
commit 77d8758e2c
22 changed files with 921 additions and 58 deletions

View File

@@ -51,6 +51,7 @@ const MESSAGE_DEFAULT_PROPS = {
openLink: shouldNeverBeCalled,
previews: [],
retryMessageSend: shouldNeverBeCalled,
sendPollVote: shouldNeverBeCalled,
pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => <div />,
renderingContext: 'EditHistoryMessagesModal',

View File

@@ -65,6 +65,7 @@ const MESSAGE_DEFAULT_PROPS = {
openLink: shouldNeverBeCalled,
previews: [],
retryMessageSend: shouldNeverBeCalled,
sendPollVote: shouldNeverBeCalled,
pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => <div />,
saveAttachment: shouldNeverBeCalled,

View File

@@ -359,6 +359,10 @@ export type PropsActions = {
openGiftBadge: (messageId: string) => void;
pushPanelForConversation: PushPanelForConversationActionType;
retryMessageSend: (messageId: string) => unknown;
sendPollVote: (params: {
messageId: string;
optionIndexes: ReadonlyArray<number>;
}) => void;
showContactModal: (contactId: string, conversationId?: string) => void;
showSpoiler: (messageId: string, data: Record<number, boolean>) => void;
@@ -2019,12 +2023,18 @@ export class Message extends React.PureComponent<Props, State> {
}
public renderPoll(): JSX.Element | null {
const { poll, direction, i18n } = this.props;
const { poll, direction, i18n, id } = this.props;
if (!poll || !isPollReceiveEnabled()) {
return null;
}
return (
<PollMessageContents poll={poll} direction={direction} i18n={i18n} />
<PollMessageContents
poll={poll}
direction={direction}
i18n={i18n}
messageId={id}
sendPollVote={this.props.sendPollVote}
/>
);
}

View File

@@ -98,6 +98,7 @@ export type PropsReduxActions = Pick<
| 'openGiftBadge'
| 'pushPanelForConversation'
| 'retryMessageSend'
| 'sendPollVote'
| 'saveAttachment'
| 'saveAttachments'
| 'showContactModal'
@@ -146,6 +147,7 @@ export function MessageDetail({
platform,
pushPanelForConversation,
retryMessageSend,
sendPollVote,
renderAudioAttachment,
saveAttachment,
saveAttachments,
@@ -354,6 +356,7 @@ export function MessageDetail({
platform={platform}
pushPanelForConversation={pushPanelForConversation}
retryMessageSend={retryMessageSend}
sendPollVote={sendPollVote}
renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment}
saveAttachments={saveAttachments}

View File

@@ -122,6 +122,7 @@ const defaultMessageProps: TimelineMessagesProps = {
setMessageToEdit: action('setMessageToEdit'),
setQuoteByMessageId: action('default--setQuoteByMessageId'),
retryMessageSend: action('default--retryMessageSend'),
sendPollVote: action('default--sendPollVote'),
copyMessageText: action('copyMessageText'),
retryDeleteForEveryone: action('default--retryDeleteForEveryone'),
saveAttachment: action('saveAttachment'),

View File

@@ -292,6 +292,7 @@ const actions = () => ({
copyMessageText: action('copyMessageText'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'),
sendPollVote: action('sendPollVote'),
saveAttachment: action('saveAttachment'),
saveAttachments: action('saveAttachments'),
pushPanelForConversation: action('pushPanelForConversation'),

View File

@@ -56,6 +56,7 @@ const getDefaultProps = () => ({
copyMessageText: action('copyMessageText'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'),
sendPollVote: action('sendPollVote'),
blockGroupLinkRequests: action('blockGroupLinkRequests'),
cancelAttachmentDownload: action('cancelAttachmentDownload'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),

View File

@@ -305,6 +305,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
saveAttachments: action('saveAttachments'),
setQuoteByMessageId: action('setQuoteByMessageId'),
retryMessageSend: action('retryMessageSend'),
sendPollVote: action('sendPollVote'),
copyMessageText: action('copyMessageText'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),

View File

@@ -75,6 +75,10 @@ export type PropsActions = {
{ emoji, remove }: { emoji: string; remove: boolean }
) => void;
retryMessageSend: (id: string) => void;
sendPollVote: (params: {
messageId: string;
optionIndexes: ReadonlyArray<number>;
}) => void;
copyMessageText: (id: string) => void;
retryDeleteForEveryone: (id: string) => void;
setMessageToEdit: (conversationId: string, messageId: string) => unknown;

View File

@@ -83,12 +83,19 @@ export type PollMessageContentsProps = {
poll: PollWithResolvedVotersType;
direction: DirectionType;
i18n: LocalizerType;
messageId: string;
sendPollVote: (params: {
messageId: string;
optionIndexes: ReadonlyArray<number>;
}) => void;
};
export function PollMessageContents({
poll,
direction,
i18n,
messageId,
sendPollVote,
}: PollMessageContentsProps): JSX.Element {
const [showVotesModal, setShowVotesModal] = useState(false);
const isIncoming = direction === 'incoming';
@@ -104,6 +111,35 @@ export function PollMessageContents({
pollStatusText = i18n('icu:PollMessage--SelectOne');
}
async function handlePollOptionClicked(
index: number,
nextChecked: boolean
): Promise<void> {
const existingSelections = Array.from(
poll.votesByOption
.entries()
.filter(([_, voters]) => (voters ?? []).some(v => v.isMe))
.map(([optionIndex]) => optionIndex)
);
const optionIndexes = new Set<number>(existingSelections);
if (nextChecked) {
if (!poll.allowMultiple) {
// Single-select: clear existing selections first
optionIndexes.clear();
}
optionIndexes.add(index);
} else {
// Removing a selection - same for both modes
optionIndexes.delete(index);
}
sendPollVote({
messageId,
optionIndexes: [...optionIndexes],
});
}
return (
<div
className={tw(
@@ -133,9 +169,7 @@ export function PollMessageContents({
const percentage =
totalVotes > 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({
<div className={tw('mt-[3px] self-start')}>
<PollCheckbox
checked={weVotedForThis}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onCheckedChange={() => {}}
onCheckedChange={next =>
handlePollOptionClicked(index, Boolean(next))
}
isIncoming={isIncoming}
/>
</div>

View File

@@ -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<typeof reactionJobDataSchema>;
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<typeof pollVoteJobDataSchema>;
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<ConversationQueueJobData> {
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;

View File

@@ -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<void> {
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<Error> = [];
const saveErrors = (errors: Array<Error>): 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<ServiceIdString>;
recipientServiceIdsWithoutMe: Array<ServiceIdString>;
untrustedServiceIds: Array<ServiceIdString>;
} {
const allRecipientServiceIds: Array<ServiceIdString> = [];
const recipientServiceIdsWithoutMe: Array<ServiceIdString> = [];
const untrustedServiceIds: Array<ServiceIdString> = [];
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,
};
}

View File

@@ -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<MessagePollVoteType>;
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({

View File

@@ -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<number>;
}>): Promise<void> {
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,
});
}
);
}

109
ts/polls/util.std.ts Normal file
View File

@@ -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<Pick<MessagePollVoteType, 'sendStateByConversationId'>>
): Iterable<string> {
const { sendStateByConversationId = {} } = pollVote;
for (const [id, sendState] of Object.entries(sendStateByConversationId)) {
if (!isSent(sendState.status)) {
yield id;
}
}
}
export function isOutgoingPollVoteCompletelyUnsent(
pollVote: Readonly<Pick<MessagePollVoteType, 'sendStateByConversationId'>>
): 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<MessagePollVoteType>,
targetVote: Readonly<MessagePollVoteType>,
ephemeralSendStateByConversationId: SendStateByConversationId
): Array<MessagePollVoteType> {
const result: Array<MessagePollVoteType> = [];
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<MessagePollVoteType>,
targetVote: Readonly<MessagePollVoteType>
): Array<MessagePollVoteType> {
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
);
}

View File

@@ -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<number>;
}>): ThunkAction<void, RootStateType, unknown, NoopActionType> {
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<void, RootStateType, unknown, NoopActionType> {

View File

@@ -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<PollVoteWithUserType> = 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<string, MessagePollVoteType>();
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<PollVoteWithUserType> = 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<number, Array<PollVoteWithUserType>>();
let totalNumVotes = 0;

View File

@@ -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}

View File

@@ -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}

View File

@@ -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<ServiceIdString>;
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<MessageOptionsType, 'groupV2' | 'timestamp' | 'pollVote'>
> &
Pick<MessageOptionsType, 'profileKey' | 'expireTimer' | 'expireTimerVersion'>;
class Message {
attachments: ReadonlyArray<Proto.IAttachmentPointer>;
@@ -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<Uint8Array> {
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<Proto.Content> {
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,
};
}

View File

@@ -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<z.infer<typeof PollVoteSchema>>;
// PollTerminate schema (processed shape)
// - targetTimestamp: required, positive int
@@ -70,6 +69,7 @@ export type MessagePollVoteType = {
optionIndexes: ReadonlyArray<number>;
voteCount: number;
timestamp: number;
sendStateByConversationId?: SendStateByConversationId;
};
export type PollMessageAttribute = {

View File

@@ -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