Polls: Longer question length and 1:1 Receive Support

Co-authored-by: jimio <jimio@jimio-m3-max.local>
Co-authored-by: Yash <yash@signal.org>
This commit is contained in:
jimio
2026-01-05 14:00:42 -08:00
committed by GitHub
parent 8030284a40
commit 0400da993c
6 changed files with 145 additions and 89 deletions

View File

@@ -18,7 +18,8 @@ import { getEmojiVariantByKey } from './fun/data/emojis.std.js';
import { strictAssert } from '../util/assert.std.js';
import {
type PollCreateType,
POLL_QUESTION_MAX_LENGTH,
POLL_QUESTION_MAX_LENGTH_SEND,
POLL_OPTION_MAX_LENGTH,
POLL_OPTIONS_MIN_COUNT,
POLL_OPTIONS_MAX_COUNT,
} from '../types/Polls.dom.js';
@@ -188,7 +189,7 @@ export function PollCreateModal({
}
// Don't insert if it would exceed the max grapheme length
if (countGraphemes(newValue) > POLL_QUESTION_MAX_LENGTH) {
if (countGraphemes(newValue) > POLL_OPTION_MAX_LENGTH) {
return opt; // Return unchanged
}
@@ -314,8 +315,8 @@ export function PollCreateModal({
value={question}
onChange={handleQuestionChange}
placeholder={i18n('icu:PollCreateModal__questionPlaceholder')}
maxLengthCount={POLL_QUESTION_MAX_LENGTH}
whenToShowRemainingCount={POLL_QUESTION_MAX_LENGTH - 30}
maxLengthCount={POLL_QUESTION_MAX_LENGTH_SEND}
whenToShowRemainingCount={POLL_QUESTION_MAX_LENGTH_SEND - 30}
aria-invalid={validationErrors.question || undefined}
aria-errormessage={
validationErrors.question ? 'poll-question-error' : undefined
@@ -340,8 +341,8 @@ export function PollCreateModal({
placeholder={i18n('icu:PollCreateModal__optionPlaceholder', {
number: String(index + 1),
})}
maxLengthCount={POLL_QUESTION_MAX_LENGTH}
whenToShowRemainingCount={POLL_QUESTION_MAX_LENGTH - 30}
maxLengthCount={POLL_OPTION_MAX_LENGTH}
whenToShowRemainingCount={POLL_OPTION_MAX_LENGTH - 30}
aria-invalid={validationErrors.options || undefined}
aria-errormessage={
validationErrors.options ? 'poll-options-error' : undefined

View File

@@ -3,7 +3,6 @@
import { ContentHint } from '@signalapp/libsignal-client';
import * as Errors from '../../types/errors.std.js';
import { isGroupV2 } 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';
@@ -28,6 +27,11 @@ import type {
import * as pollVoteUtil from '../../polls/util.std.js';
import { strictAssert } from '../../util/assert.std.js';
import { getSendRecipientLists } from './getSendRecipientLists.dom.js';
import { isDirectConversation } from '../../util/whatTypeOfConversation.dom.js';
import { isConversationAccepted } from '../../util/isConversationAccepted.preload.js';
import { isConversationUnregistered } from '../../util/isConversationUnregistered.dom.js';
import type { CallbackResultType } from '../../textsecure/Types.d.ts';
import { addPniSignatureMessageToProto } from '../../textsecure/SendMessage.preload.js';
export async function sendPollVote(
conversation: ConversationModel,
@@ -52,10 +56,6 @@ export async function sendPollVote(
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;
@@ -173,10 +173,14 @@ export async function sendPollVote(
if (recipientServiceIdsWithoutMe.length === 0) {
jobLog.info('sending sync poll vote message only');
const groupV2Info = conversation.getGroupV2Info({
members: recipientServiceIdsWithoutMe,
});
if (!groupV2Info) {
const groupV2Info = isDirectConversation(conversation.attributes)
? undefined
: conversation.getGroupV2Info({
members: recipientServiceIdsWithoutMe,
});
if (!groupV2Info && !isDirectConversation(conversation.attributes)) {
jobLog.error(
'sendPollVote: Missing groupV2Info for group conversation'
);
@@ -207,52 +211,107 @@ export async function sendPollVote(
} else {
const sendOptions = await getSendOptions(conversation.attributes);
const promise = conversation.queueJob(
'conversationQueue/sendPollVote',
async abortSignal => {
const groupV2Info = conversation.getGroupV2Info({
members: recipientServiceIdsWithoutMe,
});
strictAssert(
groupV2Info,
'could not get group info from conversation'
let promise: Promise<CallbackResultType>;
if (isDirectConversation(conversation.attributes)) {
if (!isConversationAccepted(conversation.attributes)) {
jobLog.info(
`conversation ${conversation.idForLogging()} is not accepted; refusing to send`
);
if (revision != null) {
groupV2Info.revision = revision;
}
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,
});
return;
}
);
if (isConversationUnregistered(conversation.attributes)) {
jobLog.info(
`conversation ${conversation.idForLogging()} is unregistered; refusing to send`
);
return;
}
if (conversation.isBlocked()) {
jobLog.info(
`conversation ${conversation.idForLogging()} is blocked; refusing to send`
);
return;
}
jobLog.info('sending direct poll vote message');
const contentMessage = await messaging.getPollVoteContentMessage({
groupV2: undefined,
timestamp: currentTimestamp,
profileKey,
expireTimer,
expireTimerVersion: conversation.getExpireTimerVersion(),
pollVote: {
targetAuthorAci: data.targetAuthorAci,
targetTimestamp: data.targetTimestamp,
optionIndexes: currentOptionIndexes,
voteCount: currentVoteCount,
},
});
addPniSignatureMessageToProto({
conversation,
proto: contentMessage,
reason: `sendPollVote(${currentTimestamp})`,
});
promise = messaging.sendMessageProtoAndWait({
timestamp: currentTimestamp,
recipients: [recipientServiceIdsWithoutMe[0]],
proto: contentMessage,
contentHint: ContentHint.Resendable,
groupId: undefined,
options: sendOptions,
urgent: true,
});
} else {
jobLog.info('sending group poll vote message');
promise = conversation.queueJob(
'conversationQueue/sendPollVote',
async abortSignal => {
const groupV2Info = conversation.getGroupV2Info({
members: recipientServiceIdsWithoutMe,
});
strictAssert(
groupV2Info,
'could not get group info from conversation'
);
if (revision != null) {
groupV2Info.revision = revision;
}
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,
});
}
);
}
const messageSendPromise = send(ephemeral, {
promise: handleMessageSend(promise, {

View File

@@ -477,13 +477,6 @@ export async function handleDataMessage(
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

View File

@@ -15,7 +15,6 @@ import type { PollVoteAttributesType } from '../messageModifiers/Polls.preload.j
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';
@@ -38,10 +37,6 @@ export async function enqueuePollVoteForSend({
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);

View File

@@ -243,9 +243,12 @@ export type GroupMessageOptionsType = Readonly<
>;
export type PollVoteBuildOptions = Required<
Pick<MessageOptionsType, 'groupV2' | 'timestamp' | 'pollVote'>
Pick<MessageOptionsType, 'timestamp' | 'pollVote'>
> &
Pick<MessageOptionsType, 'profileKey' | 'expireTimer' | 'expireTimerVersion'>;
Pick<
MessageOptionsType,
'groupV2' | 'profileKey' | 'expireTimer' | 'expireTimerVersion'
>;
export type PollTerminateBuildOptions = Required<
Pick<MessageOptionsType, 'groupV2' | 'timestamp' | 'pollTerminate'>
@@ -689,13 +692,13 @@ class Message {
}
}
type AddPniSignatureMessageToProtoOptionsType = Readonly<{
export type AddPniSignatureMessageToProtoOptionsType = Readonly<{
conversation?: ConversationModel;
proto: Proto.Content;
reason: string;
}>;
function addPniSignatureMessageToProto({
export function addPniSignatureMessageToProto({
conversation,
proto,
reason,
@@ -838,10 +841,12 @@ export class MessageSender {
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 (groupV2) {
const groupContext = new Proto.GroupContextV2();
groupContext.masterKey = groupV2.masterKey;
groupContext.revision = groupV2.revision;
dataMessage.groupV2 = groupContext;
}
if (typeof expireTimer !== 'undefined') {
dataMessage.expireTimer = expireTimer;

View File

@@ -14,7 +14,10 @@ import type { SendStateByConversationId } from '../messages/MessageSendState.std
import { aciSchema } from './ServiceId.std.js';
import { MAX_MESSAGE_BODY_BYTE_LENGTH } from '../util/longAttachment.std.js';
export const POLL_QUESTION_MAX_LENGTH = 100;
// temporarily limit poll questions to an outbound 100 char and an inbound 200 char
export const POLL_QUESTION_MAX_LENGTH_RECEIVE = 200;
export const POLL_QUESTION_MAX_LENGTH_SEND = 100;
export const POLL_OPTION_MAX_LENGTH = 100;
export const POLL_OPTIONS_MIN_COUNT = 2;
export const POLL_OPTIONS_MAX_COUNT = 10;
@@ -27,9 +30,12 @@ export const PollCreateSchema = z
question: z
.string()
.min(1)
.refine(value => hasAtMostGraphemes(value, POLL_QUESTION_MAX_LENGTH), {
message: `question must contain at most ${POLL_QUESTION_MAX_LENGTH} characters`,
})
.refine(
value => hasAtMostGraphemes(value, POLL_QUESTION_MAX_LENGTH_RECEIVE),
{
message: `question must contain at most ${POLL_QUESTION_MAX_LENGTH_RECEIVE} characters`,
}
)
.refine(
value => Buffer.byteLength(value) <= MAX_MESSAGE_BODY_BYTE_LENGTH,
{
@@ -41,12 +47,9 @@ export const PollCreateSchema = z
z
.string()
.min(1)
.refine(
value => hasAtMostGraphemes(value, POLL_QUESTION_MAX_LENGTH),
{
message: `option must contain at most ${POLL_QUESTION_MAX_LENGTH} characters`,
}
)
.refine(value => hasAtMostGraphemes(value, POLL_OPTION_MAX_LENGTH), {
message: `option must contain at most ${POLL_OPTION_MAX_LENGTH} characters`,
})
.refine(
value => Buffer.byteLength(value) <= MAX_MESSAGE_BODY_BYTE_LENGTH,
{