From 93ae2a4c48276cdd2e586371cc58c8a36209f0d5 Mon Sep 17 00:00:00 2001 From: yash-signal Date: Thu, 18 Sep 2025 11:06:43 -0500 Subject: [PATCH] Initial Poll message receive support --- protos/SignalService.proto | 22 +- ts/RemoteConfig.ts | 3 + ts/background.ts | 189 +++++++++ ts/components/conversation/Message.tsx | 23 ++ ts/messageModifiers/Polls.ts | 533 +++++++++++++++++++++++++ ts/messages/handleDataMessage.ts | 40 ++ ts/model-types.d.ts | 2 + ts/state/selectors/message.ts | 1 + ts/test-node/util/grapheme_test.ts | 24 +- ts/textsecure/Types.d.ts | 20 + ts/textsecure/processDataMessage.ts | 53 +++ ts/types/Polls.ts | 109 +++++ ts/util/grapheme.ts | 29 +- ts/util/isMessageEmpty.ts | 2 + ts/util/modifyTargetMessage.ts | 28 ++ 15 files changed, 1072 insertions(+), 6 deletions(-) create mode 100644 ts/messageModifiers/Polls.ts create mode 100644 ts/types/Polls.ts diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 332549fef8..92ed3ac50e 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -372,6 +372,23 @@ message DataMessage { optional bytes receiptCredentialPresentation = 1; } + message PollCreate { + optional string question = 1; + optional bool allowMultiple = 2; + repeated string options = 3; + } + + message PollTerminate { + optional uint64 targetSentTimestamp = 1; + } + + message PollVote { + optional bytes targetAuthorAciBinary = 1; + optional uint64 targetSentTimestamp = 2; + repeated uint32 optionIndexes = 3; + optional uint32 voteCount = 4; + } + optional string body = 1; repeated AttachmentPointer attachments = 2; reserved /*groupV1*/ 3; @@ -394,7 +411,10 @@ message DataMessage { optional Payment payment = 20; optional StoryContext storyContext = 21; optional GiftBadge giftBadge = 22; - // NEXT ID: 24 + optional PollCreate pollCreate = 24; + optional PollTerminate pollTerminate = 25; + optional PollVote pollVote = 26; + // NEXT ID: 27 } message NullMessage { diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index f7d1a02ad3..6fa5b8a497 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -37,6 +37,9 @@ const KnownConfigKeys = [ 'desktop.funPicker', // alpha 'desktop.funPicker.beta', 'desktop.funPicker.prod', + 'desktop.pollReceive.alpha', + 'desktop.pollReceive.beta', + 'desktop.pollReceive.prod', 'desktop.usePqRatchet', 'global.attachments.maxBytes', 'global.attachments.maxReceiveBytes', diff --git a/ts/background.ts b/ts/background.ts index 404b6336c9..667ebc7e1d 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -58,6 +58,12 @@ import { updateIdentityKey } from './services/profiles.js'; import { RoutineProfileRefresher } from './routineProfileRefresh.js'; import { isOlderThan } from './util/timestamp.js'; import { isValidReactionEmoji } from './reactions/isValidReactionEmoji.js'; +import { safeParsePartial } from './util/schemas.js'; +import { + PollVoteSchema, + PollTerminateSchema, + isPollReceiveEnabled, +} from './types/Polls.js'; import type { ConversationModel } from './models/conversations.js'; import { getAuthor, isIncoming } from './messages/helpers.js'; import { migrateBatchOfMessages } from './messages/migrateMessageData.js'; @@ -117,11 +123,16 @@ import * as Deletes from './messageModifiers/Deletes.js'; import * as Edits from './messageModifiers/Edits.js'; import * as MessageReceipts from './messageModifiers/MessageReceipts.js'; import * as MessageRequests from './messageModifiers/MessageRequests.js'; +import * as Polls from './messageModifiers/Polls.js'; import * as Reactions from './messageModifiers/Reactions.js'; import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs.js'; import type { DeleteAttributesType } from './messageModifiers/Deletes.js'; import type { EditAttributesType } from './messageModifiers/Edits.js'; import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests.js'; +import type { + PollVoteAttributesType, + PollTerminateAttributesType, +} from './messageModifiers/Polls.js'; import type { ReactionAttributesType } from './messageModifiers/Reactions.js'; import type { ViewOnceOpenSyncAttributesType } from './messageModifiers/ViewOnceOpenSyncs.js'; import { ReadStatus } from './messages/MessageReadStatus.js'; @@ -2490,6 +2501,100 @@ export async function startApp(): Promise { return; } + if (data.message.pollVote) { + if (!isPollReceiveEnabled()) { + log.warn('Dropping PollVote because the flag is disabled'); + confirm(); + return; + } + const { pollVote, timestamp } = data.message; + + const parsed = safeParsePartial(PollVoteSchema, pollVote); + if (!parsed.success) { + log.warn( + 'Dropping PollVote due to validation error:', + parsed.error.flatten() + ); + confirm(); + return; + } + + const validatedVote = parsed.data; + const targetAuthorAci = normalizeAci( + validatedVote.targetAuthorAci, + 'DataMessage.PollVote.targetAuthorAci' + ); + + const { conversation: fromConversation } = + window.ConversationController.maybeMergeContacts({ + e164: data.source, + aci: data.sourceAci, + reason: 'onMessageReceived:pollVote', + }); + strictAssert(fromConversation, 'PollVote without fromConversation'); + + log.info('Queuing incoming poll vote for', pollVote.targetTimestamp); + const attributes: PollVoteAttributesType = { + envelopeId: data.envelopeId, + removeFromMessageReceiverCache: confirm, + fromConversationId: fromConversation.id, + source: Polls.PollSource.FromSomeoneElse, + targetAuthorAci, + targetTimestamp: validatedVote.targetTimestamp, + optionIndexes: validatedVote.optionIndexes, + voteCount: validatedVote.voteCount, + receivedAtDate: data.receivedAtDate, + timestamp, + }; + + drop(Polls.onPollVote(attributes)); + return; + } + + if (data.message.pollTerminate) { + if (!isPollReceiveEnabled()) { + log.warn('Dropping PollTerminate because the flag is disabled'); + confirm(); + return; + } + const { pollTerminate, timestamp } = data.message; + + const parsedTerm = safeParsePartial(PollTerminateSchema, pollTerminate); + if (!parsedTerm.success) { + log.warn( + 'Dropping PollTerminate due to validation error:', + parsedTerm.error.flatten() + ); + confirm(); + return; + } + + const { conversation: fromConversation } = + window.ConversationController.maybeMergeContacts({ + e164: data.source, + aci: data.sourceAci, + reason: 'onMessageReceived:pollTerminate', + }); + strictAssert(fromConversation, 'PollTerminate without fromConversation'); + + log.info( + 'Queuing incoming poll termination for', + pollTerminate.targetTimestamp + ); + const attributes: PollTerminateAttributesType = { + envelopeId: data.envelopeId, + removeFromMessageReceiverCache: confirm, + fromConversationId: fromConversation.id, + source: Polls.PollSource.FromSomeoneElse, + targetTimestamp: parsedTerm.data.targetTimestamp, + receivedAtDate: data.receivedAtDate, + timestamp, + }; + + drop(Polls.onPollTerminate(attributes)); + return; + } + if (data.message.delete) { const { delete: del } = data.message; log.info('Queuing incoming DOE for', del.targetSentTimestamp); @@ -2897,6 +3002,90 @@ export async function startApp(): Promise { return; } + if (data.message.pollVote) { + if (!isPollReceiveEnabled()) { + log.warn('Dropping PollVote because the flag is disabled'); + confirm(); + return; + } + const { pollVote, timestamp } = data.message; + + const parsed = safeParsePartial(PollVoteSchema, pollVote); + if (!parsed.success) { + log.warn( + 'Dropping PollVote (sync) due to validation error:', + parsed.error.flatten() + ); + confirm(); + return; + } + + const validatedVote = parsed.data; + const targetAuthorAci = normalizeAci( + validatedVote.targetAuthorAci, + 'DataMessage.PollVote.targetAuthorAci' + ); + + const ourConversationId = + window.ConversationController.getOurConversationIdOrThrow(); + + log.info('Queuing sync poll vote for', pollVote.targetTimestamp); + const attributes: PollVoteAttributesType = { + envelopeId: data.envelopeId, + removeFromMessageReceiverCache: confirm, + fromConversationId: ourConversationId, + source: Polls.PollSource.FromSync, + targetAuthorAci, + targetTimestamp: validatedVote.targetTimestamp, + optionIndexes: validatedVote.optionIndexes, + voteCount: validatedVote.voteCount, + receivedAtDate: data.receivedAtDate, + timestamp, + }; + + drop(Polls.onPollVote(attributes)); + return; + } + + if (data.message.pollTerminate) { + if (!isPollReceiveEnabled()) { + log.warn('Dropping PollTerminate because the flag is disabled'); + confirm(); + return; + } + const { pollTerminate, timestamp } = data.message; + + const parsedTerm = safeParsePartial(PollTerminateSchema, pollTerminate); + if (!parsedTerm.success) { + log.warn( + 'Dropping PollTerminate (sync) due to validation error:', + parsedTerm.error.flatten() + ); + confirm(); + return; + } + + const ourConversationId = + window.ConversationController.getOurConversationIdOrThrow(); + + log.info( + 'Queuing sync poll termination for', + pollTerminate.targetTimestamp + ); + const attributes: PollTerminateAttributesType = { + envelopeId: data.envelopeId, + removeFromMessageReceiverCache: confirm, + fromConversationId: ourConversationId, + source: Polls.PollSource.FromSync, + targetTimestamp: parsedTerm.data.targetTimestamp, + receivedAtDate: data.receivedAtDate, + timestamp, + }; + + drop(Polls.onPollTerminate(attributes)); + return; + } + if (data.message.delete) { const { delete: del } = data.message; strictAssert( diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index fa15822d9c..4597622fdd 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -96,6 +96,10 @@ import { isPaymentNotificationEvent } from '../../types/Payment.js'; import type { AnyPaymentEvent } from '../../types/Payment.js'; import { getPaymentEventDescription } from '../../messages/helpers.js'; import { PanelType } from '../../types/Panels.js'; +import { + type PollMessageAttribute, + isPollReceiveEnabled, +} from '../../types/Polls.js'; import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser.js'; import { RenderLocation } from './MessageTextRenderer.js'; import { UserText } from '../UserText.js'; @@ -301,6 +305,7 @@ export type PropsData = { attachments?: ReadonlyArray; giftBadge?: GiftBadgeType; payment?: AnyPaymentEvent; + poll?: PollMessageAttribute; quote?: { conversationColor: ConversationColorType; conversationTitle: string; @@ -2074,6 +2079,23 @@ export class Message extends React.PureComponent { ); } + public renderPoll(): JSX.Element | null { + const { poll, direction } = this.props; + if (!poll || !isPollReceiveEnabled()) { + return null; + } + return ( +
+
{JSON.stringify(poll, null, 2)}
+
+ ); + } + #doubleCheckMissingQuoteReference = () => { return this.props.doubleCheckMissingQuoteReference(this.props.id); }; @@ -2973,6 +2995,7 @@ export class Message extends React.PureComponent { {this.renderPreview()} {this.renderAttachmentTooBig()} {this.renderPayment()} + {this.renderPoll()} {this.renderEmbeddedContact()} {this.renderText()} {this.renderUndownloadableTextAttachment()} diff --git a/ts/messageModifiers/Polls.ts b/ts/messageModifiers/Polls.ts new file mode 100644 index 0000000000..80c4cb1a1a --- /dev/null +++ b/ts/messageModifiers/Polls.ts @@ -0,0 +1,533 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { AciString } from '../types/ServiceId.js'; +import type { + MessageAttributesType, + ReadonlyMessageAttributesType, +} from '../model-types.d.ts'; +import type { MessagePollVoteType } from '../types/Polls.js'; +import { MessageModel } from '../models/messages.js'; +import { DataReader } from '../sql/Client.js'; +import * as Errors from '../types/errors.js'; +import { createLogger } from '../logging/log.js'; +import { getAuthor, isIncoming, isOutgoing } from '../messages/helpers.js'; + +import { isSent } from '../messages/MessageSendState.js'; +import { getPropForTimestamp } from '../util/editHelpers.js'; +import { isMe } from '../util/whatTypeOfConversation.js'; + +import { strictAssert } from '../util/assert.js'; +import { getMessageIdForLogging } from '../util/idForLogging.js'; + +const log = createLogger('Polls'); + +export enum PollSource { + FromThisDevice = 'FromThisDevice', + FromSync = 'FromSync', + FromSomeoneElse = 'FromSomeoneElse', +} + +export type PollVoteAttributesType = { + envelopeId: string; + fromConversationId: string; + removeFromMessageReceiverCache: () => unknown; + source: PollSource; + targetAuthorAci: AciString; + targetTimestamp: number; + optionIndexes: ReadonlyArray; + voteCount: number; + timestamp: number; + receivedAtDate: number; +}; + +export type PollTerminateAttributesType = { + envelopeId: string; + fromConversationId: string; + removeFromMessageReceiverCache: () => unknown; + source: PollSource; + targetTimestamp: number; + timestamp: number; + receivedAtDate: number; +}; + +const pollVoteCache = new Map(); +const pollTerminateCache = new Map(); + +function removeVote(vote: PollVoteAttributesType): void { + pollVoteCache.delete(vote.envelopeId); + vote.removeFromMessageReceiverCache(); +} + +function removeTerminate(terminate: PollTerminateAttributesType): void { + pollTerminateCache.delete(terminate.envelopeId); + terminate.removeFromMessageReceiverCache(); +} + +function doesVoteModifierMatchMessage({ + message, + targetTimestamp, + targetAuthorAci, + targetAuthorId, + voteSenderConversationId, +}: { + message: ReadonlyMessageAttributesType; + targetTimestamp: number; + targetAuthorAci?: string; + targetAuthorId?: string; + voteSenderConversationId: string; +}): boolean { + if (message.sent_at !== targetTimestamp) { + return false; + } + + const author = getAuthor(message); + if (!author) { + return false; + } + + const targetAuthorConversation = window.ConversationController.get( + targetAuthorAci ?? targetAuthorId + ); + if (!targetAuthorConversation) { + return false; + } + + if (author.id !== targetAuthorConversation.id) { + return false; + } + + const voteSenderConversation = window.ConversationController.get( + voteSenderConversationId + ); + if (!voteSenderConversation) { + return false; + } + + if (isMe(voteSenderConversation.attributes)) { + return true; + } + + if (isOutgoing(message)) { + const sendStateByConversationId = getPropForTimestamp({ + log, + message, + prop: 'sendStateByConversationId', + targetTimestamp, + }); + + const sendState = sendStateByConversationId?.[voteSenderConversationId]; + return !!sendState && isSent(sendState.status); + } + + if (isIncoming(message)) { + const messageConversation = window.ConversationController.get( + message.conversationId + ); + if (!messageConversation) { + return false; + } + + const voteSenderServiceId = voteSenderConversation.getServiceId(); + return ( + voteSenderServiceId != null && + messageConversation.hasMember(voteSenderServiceId) + ); + } + + return false; +} + +async function findPollMessage({ + targetTimestamp, + targetAuthorAci, + targetAuthorId, + voteSenderConversationId, + logId, +}: { + targetTimestamp: number; + targetAuthorAci?: string; + targetAuthorId?: string; + voteSenderConversationId: string; + logId: string; +}): Promise { + const messages = await DataReader.getMessagesBySentAt(targetTimestamp); + + const matchingMessages = messages.filter(message => { + if (!message.poll) { + return false; + } + + return doesVoteModifierMatchMessage({ + message, + targetTimestamp, + targetAuthorAci, + targetAuthorId, + voteSenderConversationId, + }); + }); + + if (!matchingMessages.length) { + return undefined; + } + + if (matchingMessages.length > 1) { + log.warn( + `${logId}/findPollMessage: found ${matchingMessages.length} matching messages for the poll!` + ); + } + + return matchingMessages[0]; +} + +export async function onPollVote(vote: PollVoteAttributesType): Promise { + pollVoteCache.set(vote.envelopeId, vote); + + const logId = `Polls.onPollVote(timestamp=${vote.timestamp};target=${vote.targetTimestamp})`; + + try { + const matchingMessage = await findPollMessage({ + targetTimestamp: vote.targetTimestamp, + targetAuthorAci: vote.targetAuthorAci, + voteSenderConversationId: vote.fromConversationId, + logId, + }); + + if (!matchingMessage) { + log.info( + `${logId}: No poll message for vote`, + 'targeting', + vote.targetAuthorAci + ); + return; + } + + const matchingMessageConversation = window.ConversationController.get( + matchingMessage.conversationId + ); + + if (!matchingMessageConversation) { + log.info( + `${logId}: No target conversation for poll vote`, + vote.targetAuthorAci, + vote.targetTimestamp + ); + removeVote(vote); + return undefined; + } + + // awaiting is safe since `onPollVote` is never called from inside the queue + await matchingMessageConversation.queueJob('Polls.onPollVote', async () => { + log.info(`${logId}: handling`); + + // Message is fetched inside the conversation queue so we have the + // most recent data + const targetMessage = await findPollMessage({ + targetTimestamp: vote.targetTimestamp, + targetAuthorAci: vote.targetAuthorAci, + voteSenderConversationId: vote.fromConversationId, + logId: `${logId}/conversationQueue`, + }); + + if (!targetMessage || targetMessage.id !== matchingMessage.id) { + log.warn( + `${logId}: message no longer a match for vote! Maybe it's been deleted?` + ); + removeVote(vote); + return; + } + + const targetMessageModel = window.MessageCache.register( + new MessageModel(targetMessage) + ); + + await handlePollVote(targetMessageModel, vote); + removeVote(vote); + }); + } catch (error) { + removeVote(vote); + log.error(`${logId} error:`, Errors.toLogFormat(error)); + } +} + +export async function onPollTerminate( + terminate: PollTerminateAttributesType +): Promise { + pollTerminateCache.set(terminate.envelopeId, terminate); + + const logId = `Polls.onPollTerminate(timestamp=${terminate.timestamp};target=${terminate.targetTimestamp})`; + + try { + // For termination, we need to find the poll by timestamp only + // The fromConversationId must be the poll creator + const matchingMessage = await findPollMessage({ + targetTimestamp: terminate.targetTimestamp, + targetAuthorId: terminate.fromConversationId, + voteSenderConversationId: terminate.fromConversationId, + logId, + }); + + if (!matchingMessage) { + log.info( + `${logId}: No poll message for termination`, + 'targeting timestamp', + terminate.targetTimestamp + ); + return; + } + + const matchingMessageConversation = window.ConversationController.get( + matchingMessage.conversationId + ); + + if (!matchingMessageConversation) { + log.info( + `${logId}: No target conversation for poll termination`, + terminate.targetTimestamp + ); + removeTerminate(terminate); + return undefined; + } + + // awaiting is safe since `onPollTerminate` is never called from inside the queue + await matchingMessageConversation.queueJob( + 'Polls.onPollTerminate', + async () => { + log.info(`${logId}: handling`); + + // Re-fetch to ensure we have the most recent data + const targetMessages = await DataReader.getMessagesBySentAt( + terminate.targetTimestamp + ); + const targetMessage = targetMessages.find( + msg => msg.id === matchingMessage.id + ); + + if (!targetMessage) { + log.warn( + `${logId}: message no longer exists! Maybe it's been deleted?` + ); + removeTerminate(terminate); + return; + } + + const targetMessageModel = window.MessageCache.register( + new MessageModel(targetMessage) + ); + + await handlePollTerminate(targetMessageModel, terminate); + removeTerminate(terminate); + } + ); + } catch (error) { + removeTerminate(terminate); + log.error(`${logId} error:`, Errors.toLogFormat(error)); + } +} + +export async function handlePollVote( + message: MessageModel, + vote: PollVoteAttributesType, + { + shouldPersist = true, + }: { + shouldPersist?: boolean; + } = {} +): Promise { + if (message.get('deletedForEveryone')) { + return; + } + + const poll = message.get('poll'); + if (!poll) { + log.warn('handlePollVote: Message is not a poll'); + return; + } + + if (poll.terminatedAt) { + log.info('handlePollVote: Poll is already terminated, ignoring vote'); + return; + } + + // Validate option indexes + const maxOptionIndex = poll.options.length - 1; + const invalidIndexes = vote.optionIndexes.filter( + index => index < 0 || index > maxOptionIndex + ); + if (invalidIndexes.length > 0) { + log.warn('handlePollVote: Invalid option indexes found, dropping'); + return; + } + + // Check multiple choice constraint + if (!poll.allowMultiple && vote.optionIndexes.length > 1) { + log.warn( + 'handlePollVote: Multiple votes not allowed for this poll, dropping' + ); + return; + } + + const conversation = window.ConversationController.get( + message.attributes.conversationId + ); + if (!conversation) { + return; + } + + const isFromThisDevice = vote.source === PollSource.FromThisDevice; + const isFromSync = vote.source === PollSource.FromSync; + const isFromSomeoneElse = vote.source === PollSource.FromSomeoneElse; + strictAssert( + isFromThisDevice || isFromSync || isFromSomeoneElse, + 'Vote can only be from this device, from sync, or from someone else' + ); + + const newVote: MessagePollVoteType = { + fromConversationId: vote.fromConversationId, + optionIndexes: vote.optionIndexes, + voteCount: vote.voteCount, + timestamp: vote.timestamp, + }; + + // Update or add vote with conflict resolution + const currentVotes: Array = poll.votes + ? [...poll.votes] + : []; + let updatedVotes: Array; + + 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 { + log.info( + 'handlePollVote: Keeping existing vote with higher or same voteCount' + ); + updatedVotes = currentVotes; + } + } else { + updatedVotes = [...currentVotes, newVote]; + } + + message.set({ + poll: { + ...poll, + votes: updatedVotes, + }, + }); + + log.info( + 'handlePollVote:', + `Done processing vote for poll ${getMessageIdForLogging(message.attributes)}.` + ); + + if (shouldPersist) { + await window.MessageCache.saveMessage(message.attributes); + window.reduxActions.conversations.markOpenConversationRead(conversation.id); + } +} + +export async function handlePollTerminate( + message: MessageModel, + terminate: PollTerminateAttributesType, + { + shouldPersist = true, + }: { + shouldPersist?: boolean; + } = {} +): Promise { + const { attributes } = message; + + if (message.get('deletedForEveryone')) { + return; + } + + const poll = message.get('poll'); + if (!poll) { + log.warn('handlePollTerminate: Message is not a poll'); + return; + } + + if (poll.terminatedAt) { + log.info('handlePollTerminate: Poll is already terminated'); + return; + } + + const conversation = window.ConversationController.get( + message.attributes.conversationId + ); + if (!conversation) { + return; + } + + // Verify the terminator is the poll creator + const author = getAuthor(attributes); + const terminatorConversation = window.ConversationController.get( + terminate.fromConversationId + ); + + if ( + !author || + !terminatorConversation || + author.id !== terminatorConversation.id + ) { + log.warn( + 'handlePollTerminate: Termination rejected - not from poll creator' + ); + return; + } + + message.set({ + poll: { + ...poll, + terminatedAt: terminate.timestamp, + }, + }); + + log.info( + 'handlePollTerminate:', + `Poll ${getMessageIdForLogging(message.attributes)} terminated at ${terminate.timestamp}` + ); + + if (shouldPersist) { + await window.MessageCache.saveMessage(message.attributes); + window.reduxActions.conversations.markOpenConversationRead(conversation.id); + } +} + +export function drainCachedVotesForMessage( + message: ReadonlyMessageAttributesType +): Array { + const matching = Array.from(pollVoteCache.values()).filter(vote => { + if (!message.poll) { + return false; + } + + return doesVoteModifierMatchMessage({ + message, + targetTimestamp: vote.targetTimestamp, + targetAuthorAci: vote.targetAuthorAci, + voteSenderConversationId: vote.fromConversationId, + }); + }); + + matching.forEach(vote => removeVote(vote)); + return matching; +} + +export function drainCachedTerminatesForMessage( + message: ReadonlyMessageAttributesType +): Array { + const matching = Array.from(pollTerminateCache.values()).filter(term => { + return message.poll && message.sent_at === term.targetTimestamp; + }); + + matching.forEach(term => removeTerminate(term)); + return matching; +} diff --git a/ts/messages/handleDataMessage.ts b/ts/messages/handleDataMessage.ts index 1648af076b..374a8c7ea9 100644 --- a/ts/messages/handleDataMessage.ts +++ b/ts/messages/handleDataMessage.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { isNumber } from 'lodash'; +import type { z } from 'zod'; import { createLogger } from '../logging/log.js'; import * as Errors from '../types/errors.js'; @@ -56,6 +57,8 @@ import { } from '../util/modifyTargetMessage.js'; import { saveAndNotify } from './saveAndNotify.js'; import { MessageModel } from '../models/messages.js'; +import { safeParsePartial } from '../util/schemas.js'; +import { PollCreateSchema, isPollReceiveEnabled } from '../types/Polls.js'; import type { SentEventData } from '../textsecure/messageReceiverEvents.js'; import type { @@ -481,6 +484,35 @@ export async function handleDataMessage( } } + let validatedPollCreate: z.infer | undefined; + if (initialMessage.pollCreate) { + if (!isPollReceiveEnabled()) { + log.warn(`${idLog}: Dropping PollCreate because flag is not enabled`); + confirm(); + return; + } + if (!isGroup(conversation.attributes)) { + log.warn( + `${idLog}: Dropping PollCreate in non-group conversation ${conversation.idForLogging()}` + ); + confirm(); + return; + } + const result = safeParsePartial( + PollCreateSchema, + initialMessage.pollCreate + ); + if (!result.success) { + log.warn( + `${idLog}: Dropping invalid PollCreate:`, + result.error.flatten() + ); + confirm(); + return; + } + validatedPollCreate = result.data; + } + const withQuoteReference = { ...message.attributes, ...initialMessage, @@ -576,6 +608,14 @@ export async function handleDataMessage( quote: dataMessage.quote, schemaVersion: dataMessage.schemaVersion, sticker: dataMessage.sticker, + poll: validatedPollCreate + ? { + question: validatedPollCreate.question, + options: validatedPollCreate.options, + allowMultiple: Boolean(validatedPollCreate.allowMultiple), + votes: [], + } + : undefined, storyId: dataMessage.storyId, }); diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index d0334e5215..141a0ea068 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -35,6 +35,7 @@ import type { StorySendMode } from './types/Stories.js'; import type { MIMEType } from './types/MIME.js'; import type { DurationInSeconds } from './util/durations/index.js'; import type { AnyPaymentEvent } from './types/Payment.js'; +import type { PollMessageAttribute } from './types/Polls.js'; import AccessRequiredEnum = Proto.AccessControl.AccessRequired; import MemberRoleEnum = Proto.Member.Role; @@ -205,6 +206,7 @@ export type MessageAttributesType = { payment?: AnyPaymentEvent; quote?: QuotedMessageType; reactions?: ReadonlyArray; + poll?: PollMessageAttribute; requiredProtocolVersion?: number; sms?: boolean; sourceDevice?: number; diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index a4d4df98ad..c595b036f1 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -780,6 +780,7 @@ export const getPropsForMessage = ( expirationStartTimestamp, }), giftBadge: message.giftBadge, + poll: message.poll, id: message.id, isBlocked: conversation.isBlocked || false, isEditedMessage: Boolean(message.editHistory), diff --git a/ts/test-node/util/grapheme_test.ts b/ts/test-node/util/grapheme_test.ts index 3d1cccdf0b..6bed559d5f 100644 --- a/ts/test-node/util/grapheme_test.ts +++ b/ts/test-node/util/grapheme_test.ts @@ -3,7 +3,12 @@ import { assert } from 'chai'; -import { getGraphemes, count, isSingleGrapheme } from '../../util/grapheme.js'; +import { + getGraphemes, + count, + hasAtMostGraphemes, + isSingleGrapheme, +} from '../../util/grapheme.js'; describe('grapheme utilities', () => { describe('getGraphemes', () => { @@ -79,4 +84,21 @@ describe('grapheme utilities', () => { assert.isFalse(isSingleGrapheme('😍a')); }); }); + + describe('hasAtMostGraphemes', () => { + it('returns true when the string is within the limit', () => { + assert.isTrue(hasAtMostGraphemes('', 0)); + assert.isTrue(hasAtMostGraphemes('πŸ‘©β€β€οΈβ€πŸ‘©', 1)); + assert.isTrue(hasAtMostGraphemes('πŸ‘ŒπŸ½πŸ‘ŒπŸΎπŸ‘ŒπŸΏ', 3)); + }); + + it('returns false when the string exceeds the limit', () => { + assert.isFalse(hasAtMostGraphemes('πŸ‘ŒπŸ½πŸ‘ŒπŸΎπŸ‘ŒπŸΏ', 2)); + assert.isFalse(hasAtMostGraphemes('abc', 2)); + }); + + it('returns false for negative limits', () => { + assert.isFalse(hasAtMostGraphemes('anything', -1)); + }); + }); }); diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 7539bad806..6a36b253da 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -185,6 +185,23 @@ export type ProcessedReaction = { targetTimestamp?: number; }; +export type ProcessedPollCreate = { + question?: string; + options?: Array; + allowMultiple?: boolean; +}; + +export type ProcessedPollVote = { + targetAuthorAci?: AciString; + targetTimestamp?: number; + optionIndexes?: Array; + voteCount?: number; +}; + +export type ProcessedPollTerminate = { + targetTimestamp?: number; +}; + export type ProcessedDelete = { targetSentTimestamp?: number; }; @@ -226,6 +243,9 @@ export type ProcessedDataMessage = { isStory?: boolean; isViewOnce: boolean; reaction?: ProcessedReaction; + pollCreate?: ProcessedPollCreate; + pollVote?: ProcessedPollVote; + pollTerminate?: ProcessedPollTerminate; delete?: ProcessedDelete; bodyRanges?: ReadonlyArray; groupCallUpdate?: ProcessedGroupCallUpdate; diff --git a/ts/textsecure/processDataMessage.ts b/ts/textsecure/processDataMessage.ts index 1bf18d629b..3087fceb09 100644 --- a/ts/textsecure/processDataMessage.ts +++ b/ts/textsecure/processDataMessage.ts @@ -22,6 +22,9 @@ import type { ProcessedPreview, ProcessedSticker, ProcessedReaction, + ProcessedPollCreate, + ProcessedPollVote, + ProcessedPollTerminate, ProcessedDelete, ProcessedGiftBadge, ProcessedStoryContext, @@ -309,6 +312,53 @@ export function processReaction( }; } +export function processPollCreate( + pollCreate?: Proto.DataMessage.IPollCreate | null +): ProcessedPollCreate | undefined { + if (!pollCreate) { + return undefined; + } + + return { + question: dropNull(pollCreate.question), + options: pollCreate.options?.filter(isNotNil) || [], + allowMultiple: Boolean(pollCreate.allowMultiple), + }; +} + +export function processPollVote( + pollVote?: Proto.DataMessage.IPollVote | null +): ProcessedPollVote | undefined { + if (!pollVote) { + return undefined; + } + + const targetAuthorAci = fromAciUuidBytesOrString( + pollVote.targetAuthorAciBinary, + undefined, + 'PollVote.targetAuthorAci' + ); + + return { + targetAuthorAci, + targetTimestamp: pollVote.targetSentTimestamp?.toNumber(), + optionIndexes: pollVote.optionIndexes?.filter(isNotNil) || [], + voteCount: pollVote.voteCount || 0, + }; +} + +export function processPollTerminate( + pollTerminate?: Proto.DataMessage.IPollTerminate | null +): ProcessedPollTerminate | undefined { + if (!pollTerminate) { + return undefined; + } + + return { + targetTimestamp: pollTerminate.targetSentTimestamp?.toNumber(), + }; +} + export function processDelete( del?: Proto.DataMessage.IDelete | null ): ProcessedDelete | undefined { @@ -407,6 +457,9 @@ export function processDataMessage( requiredProtocolVersion: dropNull(message.requiredProtocolVersion), isViewOnce: Boolean(message.isViewOnce), reaction: processReaction(message.reaction), + pollCreate: processPollCreate(message.pollCreate), + pollVote: processPollVote(message.pollVote), + pollTerminate: processPollTerminate(message.pollTerminate), delete: processDelete(message.delete), bodyRanges: filterAndClean(message.bodyRanges), groupCallUpdate: dropNull(message.groupCallUpdate), diff --git a/ts/types/Polls.ts b/ts/types/Polls.ts new file mode 100644 index 0000000000..fe5179a499 --- /dev/null +++ b/ts/types/Polls.ts @@ -0,0 +1,109 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { z } from 'zod'; +import { isAciString } from '../util/isAciString.js'; +import { hasAtMostGraphemes } from '../util/grapheme.js'; +import { + Environment, + getEnvironment, + isMockEnvironment, +} from '../environment.js'; +import * as RemoteConfig from '../RemoteConfig.js'; +import { isAlpha, isBeta, isProduction } from '../util/version.js'; + +// PollCreate schema (processed shape) +// - question: required, 1..100 chars +// - options: required, 2..10 items; each 1..100 chars +// - allowMultiple: optional boolean +export const PollCreateSchema = z + .object({ + question: z + .string() + .min(1) + .refine(value => hasAtMostGraphemes(value, 100), { + message: 'question must contain at most 100 characters', + }), + options: z + .array( + z + .string() + .min(1) + .refine(value => hasAtMostGraphemes(value, 100), { + message: 'option must contain at most 100 characters', + }) + ) + .min(2) + .max(10) + .readonly(), + allowMultiple: z.boolean().optional(), + }) + .describe('PollCreate'); + +// PollVote schema (processed shape) +// - targetAuthorAci: required, non-empty ACI string +// - targetTimestamp: required, positive int +// - optionIndexes: required, 1..10 ints in [0, 9] +// - 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'), + targetTimestamp: z.number().int().positive(), + optionIndexes: z.array(z.number().int().min(0).max(9)).min(1).max(10), + voteCount: z.number().int().min(0), + }) + .describe('PollVote'); + +// PollTerminate schema (processed shape) +// - targetTimestamp: required, positive int +export const PollTerminateSchema = z + .object({ + targetTimestamp: z.number().int().positive(), + }) + .describe('PollTerminate'); + +export type MessagePollVoteType = { + fromConversationId: string; + optionIndexes: ReadonlyArray; + voteCount: number; + timestamp: number; +}; + +export type PollMessageAttribute = { + question: string; + options: ReadonlyArray; + allowMultiple: boolean; + votes?: ReadonlyArray; + terminatedAt?: number; +}; + +export function isPollReceiveEnabled(): boolean { + const env = getEnvironment(); + + if ( + env === Environment.Development || + env === Environment.Test || + isMockEnvironment() + ) { + return true; + } + + const version = window.getVersion?.(); + + if (version != null) { + if (isProduction(version)) { + return RemoteConfig.isEnabled('desktop.pollReceive.prod'); + } + if (isBeta(version)) { + return RemoteConfig.isEnabled('desktop.pollReceive.beta'); + } + if (isAlpha(version)) { + return RemoteConfig.isEnabled('desktop.pollReceive.alpha'); + } + } + + return false; +} diff --git a/ts/util/grapheme.ts b/ts/util/grapheme.ts index fa29056833..c9d09d47f5 100644 --- a/ts/util/grapheme.ts +++ b/ts/util/grapheme.ts @@ -1,15 +1,19 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import memoizee from 'memoizee'; + import { map, size, take, join } from './iterables.js'; +const getSegmenter = memoizee((): Intl.Segmenter => new Intl.Segmenter()); + export function getGraphemes(str: string): Iterable { - const segments = new Intl.Segmenter().segment(str); + const segments = getSegmenter().segment(str); return map(segments, s => s.segment); } export function count(str: string): number { - const segments = new Intl.Segmenter().segment(str); + const segments = getSegmenter().segment(str); return size(segments); } @@ -18,7 +22,7 @@ export function truncateAndSize( str: string, toSize?: number ): [string, number] { - const segments = new Intl.Segmenter().segment(str); + const segments = getSegmenter().segment(str); const originalSize = size(segments); if (toSize === undefined || originalSize <= toSize) { return [str, originalSize]; @@ -36,6 +40,23 @@ export function isSingleGrapheme(str: string): boolean { if (str === '') { return false; } - const segments = new Intl.Segmenter().segment(str); + const segments = getSegmenter().segment(str); return segments.containing(0).segment === str; } + +export function hasAtMostGraphemes(str: string, max: number): boolean { + if (max < 0) { + return false; + } + + let countSoFar = 0; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of getSegmenter().segment(str)) { + countSoFar += 1; + if (countSoFar > max) { + return false; + } + } + + return true; +} diff --git a/ts/util/isMessageEmpty.ts b/ts/util/isMessageEmpty.ts index 30d3b6ada3..f5b4935d84 100644 --- a/ts/util/isMessageEmpty.ts +++ b/ts/util/isMessageEmpty.ts @@ -30,6 +30,7 @@ export function isMessageEmpty(attributes: MessageAttributesType): boolean { const hasAttachment = (attributes.attachments || []).length > 0; const hasEmbeddedContact = (attributes.contact || []).length > 0; const isSticker = Boolean(attributes.sticker); + const isPoll = Boolean(attributes.poll); // Rendered sync messages const isCallHistoryValue = isCallHistory(attributes); @@ -69,6 +70,7 @@ export function isMessageEmpty(attributes: MessageAttributesType): boolean { hasAttachment || hasEmbeddedContact || isSticker || + isPoll || isPayment || // Rendered sync messages isCallHistoryValue || diff --git a/ts/util/modifyTargetMessage.ts b/ts/util/modifyTargetMessage.ts index 7d063e50e7..12aaeebbf0 100644 --- a/ts/util/modifyTargetMessage.ts +++ b/ts/util/modifyTargetMessage.ts @@ -40,6 +40,12 @@ import { import { getMessageIdForLogging } from './idForLogging.js'; import { markViewOnceMessageViewed } from '../services/MessageUpdater.js'; import { handleReaction } from '../messageModifiers/Reactions.js'; +import { + drainCachedTerminatesForMessage as drainCachedPollTerminatesForMessage, + drainCachedVotesForMessage as drainCachedPollVotesForMessage, + handlePollTerminate, + handlePollVote, +} from '../messageModifiers/Polls.js'; const log = createLogger('modifyTargetMessage'); @@ -315,6 +321,28 @@ export async function modifyTargetMessage( }) ); + const pollVotes = drainCachedPollVotesForMessage(message.attributes); + if (pollVotes.length) { + changed = true; + await Promise.all( + pollVotes.map(vote => + handlePollVote(message, vote, { shouldPersist: false }) + ) + ); + } + + const pollTerminates = drainCachedPollTerminatesForMessage( + message.attributes + ); + if (pollTerminates.length) { + changed = true; + await Promise.all( + pollTerminates.map(term => + handlePollTerminate(message, term, { shouldPersist: false }) + ) + ); + } + // Does message message have any pending, previously-received associated // delete for everyone messages? const deletes = Deletes.forMessage(message.attributes);