Initial Poll message receive support

This commit is contained in:
yash-signal
2025-09-18 11:06:43 -05:00
committed by GitHub
parent 976a3135e5
commit 93ae2a4c48
15 changed files with 1072 additions and 6 deletions

View File

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

View File

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

View File

@@ -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<void> {
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<void> {
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(

View File

@@ -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<AttachmentForUIType>;
giftBadge?: GiftBadgeType;
payment?: AnyPaymentEvent;
poll?: PollMessageAttribute;
quote?: {
conversationColor: ConversationColorType;
conversationTitle: string;
@@ -2074,6 +2079,23 @@ export class Message extends React.PureComponent<Props, State> {
);
}
public renderPoll(): JSX.Element | null {
const { poll, direction } = this.props;
if (!poll || !isPollReceiveEnabled()) {
return null;
}
return (
<div
className={classNames(
'module-message__text',
`module-message__text--${direction}`
)}
>
<pre>{JSON.stringify(poll, null, 2)}</pre>
</div>
);
}
#doubleCheckMissingQuoteReference = () => {
return this.props.doubleCheckMissingQuoteReference(this.props.id);
};
@@ -2973,6 +2995,7 @@ export class Message extends React.PureComponent<Props, State> {
{this.renderPreview()}
{this.renderAttachmentTooBig()}
{this.renderPayment()}
{this.renderPoll()}
{this.renderEmbeddedContact()}
{this.renderText()}
{this.renderUndownloadableTextAttachment()}

View File

@@ -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<number>;
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<string, PollVoteAttributesType>();
const pollTerminateCache = new Map<string, PollTerminateAttributesType>();
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<MessageAttributesType | undefined> {
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<void> {
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<void> {
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<void> {
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<MessagePollVoteType> = poll.votes
? [...poll.votes]
: [];
let updatedVotes: Array<MessagePollVoteType>;
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<void> {
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<PollVoteAttributesType> {
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<PollTerminateAttributesType> {
const matching = Array.from(pollTerminateCache.values()).filter(term => {
return message.poll && message.sent_at === term.targetTimestamp;
});
matching.forEach(term => removeTerminate(term));
return matching;
}

View File

@@ -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<typeof PollCreateSchema> | 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,
});

2
ts/model-types.d.ts vendored
View File

@@ -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<MessageReactionType>;
poll?: PollMessageAttribute;
requiredProtocolVersion?: number;
sms?: boolean;
sourceDevice?: number;

View File

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

View File

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

View File

@@ -185,6 +185,23 @@ export type ProcessedReaction = {
targetTimestamp?: number;
};
export type ProcessedPollCreate = {
question?: string;
options?: Array<string>;
allowMultiple?: boolean;
};
export type ProcessedPollVote = {
targetAuthorAci?: AciString;
targetTimestamp?: number;
optionIndexes?: Array<number>;
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<ProcessedBodyRange>;
groupCallUpdate?: ProcessedGroupCallUpdate;

View File

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

109
ts/types/Polls.ts Normal file
View File

@@ -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<number>;
voteCount: number;
timestamp: number;
};
export type PollMessageAttribute = {
question: string;
options: ReadonlyArray<string>;
allowMultiple: boolean;
votes?: ReadonlyArray<MessagePollVoteType>;
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;
}

View File

@@ -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<string> {
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;
}

View File

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

View File

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