mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
Add ability to send poll votes
This commit is contained in:
@@ -51,6 +51,7 @@ const MESSAGE_DEFAULT_PROPS = {
|
||||
openLink: shouldNeverBeCalled,
|
||||
previews: [],
|
||||
retryMessageSend: shouldNeverBeCalled,
|
||||
sendPollVote: shouldNeverBeCalled,
|
||||
pushPanelForConversation: shouldNeverBeCalled,
|
||||
renderAudioAttachment: () => <div />,
|
||||
renderingContext: 'EditHistoryMessagesModal',
|
||||
|
||||
@@ -65,6 +65,7 @@ const MESSAGE_DEFAULT_PROPS = {
|
||||
openLink: shouldNeverBeCalled,
|
||||
previews: [],
|
||||
retryMessageSend: shouldNeverBeCalled,
|
||||
sendPollVote: shouldNeverBeCalled,
|
||||
pushPanelForConversation: shouldNeverBeCalled,
|
||||
renderAudioAttachment: () => <div />,
|
||||
saveAttachment: shouldNeverBeCalled,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
403
ts/jobs/helpers/sendPollVote.preload.ts
Normal file
403
ts/jobs/helpers/sendPollVote.preload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
97
ts/polls/enqueuePollVoteForSend.preload.ts
Normal file
97
ts/polls/enqueuePollVoteForSend.preload.ts
Normal 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
109
ts/polls/util.std.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user