From 77d8758e2c16ebfee2f12a798e9bc84bc5028c60 Mon Sep 17 00:00:00 2001 From: yash-signal Date: Tue, 21 Oct 2025 17:09:51 -0500 Subject: [PATCH] Add ability to send poll votes --- .../EditHistoryMessagesModal.dom.tsx | 1 + ts/components/StoryViewsNRepliesModal.dom.tsx | 1 + ts/components/conversation/Message.dom.tsx | 14 +- .../conversation/MessageDetail.dom.tsx | 3 + .../conversation/Quote.dom.stories.tsx | 1 + .../conversation/Timeline.dom.stories.tsx | 1 + .../conversation/TimelineItem.dom.stories.tsx | 1 + .../TimelineMessage.dom.stories.tsx | 1 + .../conversation/TimelineMessage.dom.tsx | 4 + .../poll-message/PollMessageContents.dom.tsx | 45 +- ts/jobs/conversationJobQueue.preload.ts | 19 + ts/jobs/helpers/sendPollVote.preload.ts | 403 ++++++++++++++++++ ts/messageModifiers/Polls.preload.ts | 73 +++- ts/polls/enqueuePollVoteForSend.preload.ts | 97 +++++ ts/polls/util.std.ts | 109 +++++ ts/state/ducks/conversations.preload.ts | 24 ++ ts/state/selectors/message.preload.ts | 70 +-- ts/state/smart/MessageDetail.preload.tsx | 2 + ts/state/smart/TimelineItem.preload.tsx | 2 + ts/textsecure/SendMessage.preload.ts | 93 +++- ts/types/Polls.dom.ts | 14 +- ts/util/handleMessageSend.preload.ts | 1 + 22 files changed, 921 insertions(+), 58 deletions(-) create mode 100644 ts/jobs/helpers/sendPollVote.preload.ts create mode 100644 ts/polls/enqueuePollVoteForSend.preload.ts create mode 100644 ts/polls/util.std.ts diff --git a/ts/components/EditHistoryMessagesModal.dom.tsx b/ts/components/EditHistoryMessagesModal.dom.tsx index e56fbcdb91..64eca22867 100644 --- a/ts/components/EditHistoryMessagesModal.dom.tsx +++ b/ts/components/EditHistoryMessagesModal.dom.tsx @@ -51,6 +51,7 @@ const MESSAGE_DEFAULT_PROPS = { openLink: shouldNeverBeCalled, previews: [], retryMessageSend: shouldNeverBeCalled, + sendPollVote: shouldNeverBeCalled, pushPanelForConversation: shouldNeverBeCalled, renderAudioAttachment: () =>
, renderingContext: 'EditHistoryMessagesModal', diff --git a/ts/components/StoryViewsNRepliesModal.dom.tsx b/ts/components/StoryViewsNRepliesModal.dom.tsx index 7b6704b4b1..07336164f1 100644 --- a/ts/components/StoryViewsNRepliesModal.dom.tsx +++ b/ts/components/StoryViewsNRepliesModal.dom.tsx @@ -65,6 +65,7 @@ const MESSAGE_DEFAULT_PROPS = { openLink: shouldNeverBeCalled, previews: [], retryMessageSend: shouldNeverBeCalled, + sendPollVote: shouldNeverBeCalled, pushPanelForConversation: shouldNeverBeCalled, renderAudioAttachment: () =>
, saveAttachment: shouldNeverBeCalled, diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index d75ede8c1e..edc06735b8 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -359,6 +359,10 @@ export type PropsActions = { openGiftBadge: (messageId: string) => void; pushPanelForConversation: PushPanelForConversationActionType; retryMessageSend: (messageId: string) => unknown; + sendPollVote: (params: { + messageId: string; + optionIndexes: ReadonlyArray; + }) => void; showContactModal: (contactId: string, conversationId?: string) => void; showSpoiler: (messageId: string, data: Record) => void; @@ -2019,12 +2023,18 @@ export class Message extends React.PureComponent { } public renderPoll(): JSX.Element | null { - const { poll, direction, i18n } = this.props; + const { poll, direction, i18n, id } = this.props; if (!poll || !isPollReceiveEnabled()) { return null; } return ( - + ); } diff --git a/ts/components/conversation/MessageDetail.dom.tsx b/ts/components/conversation/MessageDetail.dom.tsx index d302dddca1..dcc6408916 100644 --- a/ts/components/conversation/MessageDetail.dom.tsx +++ b/ts/components/conversation/MessageDetail.dom.tsx @@ -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} diff --git a/ts/components/conversation/Quote.dom.stories.tsx b/ts/components/conversation/Quote.dom.stories.tsx index 020da774f5..8cf247adf0 100644 --- a/ts/components/conversation/Quote.dom.stories.tsx +++ b/ts/components/conversation/Quote.dom.stories.tsx @@ -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'), diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index 90cf5bf46e..38bb463947 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -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'), diff --git a/ts/components/conversation/TimelineItem.dom.stories.tsx b/ts/components/conversation/TimelineItem.dom.stories.tsx index 33ab42b746..c9cd16563e 100644 --- a/ts/components/conversation/TimelineItem.dom.stories.tsx +++ b/ts/components/conversation/TimelineItem.dom.stories.tsx @@ -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'), diff --git a/ts/components/conversation/TimelineMessage.dom.stories.tsx b/ts/components/conversation/TimelineMessage.dom.stories.tsx index 7dc0882133..f1e54f9646 100644 --- a/ts/components/conversation/TimelineMessage.dom.stories.tsx +++ b/ts/components/conversation/TimelineMessage.dom.stories.tsx @@ -305,6 +305,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ saveAttachments: action('saveAttachments'), setQuoteByMessageId: action('setQuoteByMessageId'), retryMessageSend: action('retryMessageSend'), + sendPollVote: action('sendPollVote'), copyMessageText: action('copyMessageText'), retryDeleteForEveryone: action('retryDeleteForEveryone'), scrollToQuotedMessage: action('scrollToQuotedMessage'), diff --git a/ts/components/conversation/TimelineMessage.dom.tsx b/ts/components/conversation/TimelineMessage.dom.tsx index 6ec8a316b6..c1f0552732 100644 --- a/ts/components/conversation/TimelineMessage.dom.tsx +++ b/ts/components/conversation/TimelineMessage.dom.tsx @@ -75,6 +75,10 @@ export type PropsActions = { { emoji, remove }: { emoji: string; remove: boolean } ) => void; retryMessageSend: (id: string) => void; + sendPollVote: (params: { + messageId: string; + optionIndexes: ReadonlyArray; + }) => void; copyMessageText: (id: string) => void; retryDeleteForEveryone: (id: string) => void; setMessageToEdit: (conversationId: string, messageId: string) => unknown; diff --git a/ts/components/conversation/poll-message/PollMessageContents.dom.tsx b/ts/components/conversation/poll-message/PollMessageContents.dom.tsx index e411354bec..a49549667a 100644 --- a/ts/components/conversation/poll-message/PollMessageContents.dom.tsx +++ b/ts/components/conversation/poll-message/PollMessageContents.dom.tsx @@ -83,12 +83,19 @@ export type PollMessageContentsProps = { poll: PollWithResolvedVotersType; direction: DirectionType; i18n: LocalizerType; + messageId: string; + sendPollVote: (params: { + messageId: string; + optionIndexes: ReadonlyArray; + }) => 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 { + const existingSelections = Array.from( + poll.votesByOption + .entries() + .filter(([_, voters]) => (voters ?? []).some(v => v.isMe)) + .map(([optionIndex]) => optionIndex) + ); + const optionIndexes = new Set(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 (
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({
{}} + onCheckedChange={next => + handlePollOptionClicked(index, Boolean(next)) + } isIncoming={isIncoming} />
diff --git a/ts/jobs/conversationJobQueue.preload.ts b/ts/jobs/conversationJobQueue.preload.ts index 08fcab46a5..9d6ad67d95 100644 --- a/ts/jobs/conversationJobQueue.preload.ts +++ b/ts/jobs/conversationJobQueue.preload.ts @@ -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; +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; + 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 { 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; diff --git a/ts/jobs/helpers/sendPollVote.preload.ts b/ts/jobs/helpers/sendPollVote.preload.ts new file mode 100644 index 0000000000..030fc3917d --- /dev/null +++ b/ts/jobs/helpers/sendPollVote.preload.ts @@ -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 { + 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 = []; + const saveErrors = (errors: Array): 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; + recipientServiceIdsWithoutMe: Array; + untrustedServiceIds: Array; +} { + const allRecipientServiceIds: Array = []; + const recipientServiceIdsWithoutMe: Array = []; + const untrustedServiceIds: Array = []; + + 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, + }; +} diff --git a/ts/messageModifiers/Polls.preload.ts b/ts/messageModifiers/Polls.preload.ts index c9df6ccb6b..cbb14c0b6c 100644 --- a/ts/messageModifiers/Polls.preload.ts +++ b/ts/messageModifiers/Polls.preload.ts @@ -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; - 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({ diff --git a/ts/polls/enqueuePollVoteForSend.preload.ts b/ts/polls/enqueuePollVoteForSend.preload.ts new file mode 100644 index 0000000000..a6aba65041 --- /dev/null +++ b/ts/polls/enqueuePollVoteForSend.preload.ts @@ -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; +}>): Promise { + 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, + }); + } + ); +} diff --git a/ts/polls/util.std.ts b/ts/polls/util.std.ts new file mode 100644 index 0000000000..2218d740f2 --- /dev/null +++ b/ts/polls/util.std.ts @@ -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> +): Iterable { + const { sendStateByConversationId = {} } = pollVote; + for (const [id, sendState] of Object.entries(sendStateByConversationId)) { + if (!isSent(sendState.status)) { + yield id; + } + } +} + +export function isOutgoingPollVoteCompletelyUnsent( + pollVote: Readonly> +): 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, + targetVote: Readonly, + ephemeralSendStateByConversationId: SendStateByConversationId +): Array { + const result: Array = []; + + 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, + targetVote: Readonly +): Array { + 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 + ); +} diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index 568091f0dc..f509818755 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -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; +}>): ThunkAction { + 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 { diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index 44bb23dc4a..3a84710312 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -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 = 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(); + 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 = 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>(); let totalNumVotes = 0; diff --git a/ts/state/smart/MessageDetail.preload.tsx b/ts/state/smart/MessageDetail.preload.tsx index f5dc30c82c..047d0c1cfa 100644 --- a/ts/state/smart/MessageDetail.preload.tsx +++ b/ts/state/smart/MessageDetail.preload.tsx @@ -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} diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx index c4847ceb7b..e46ad0465b 100644 --- a/ts/state/smart/TimelineItem.preload.tsx +++ b/ts/state/smart/TimelineItem.preload.tsx @@ -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} diff --git a/ts/textsecure/SendMessage.preload.ts b/ts/textsecure/SendMessage.preload.ts index d970a6ef82..481fe964f4 100644 --- a/ts/textsecure/SendMessage.preload.ts +++ b/ts/textsecure/SendMessage.preload.ts @@ -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; 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 +> & + Pick; + class Message { attachments: ReadonlyArray; @@ -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 { + 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 { + 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, }; } diff --git a/ts/types/Polls.dom.ts b/ts/types/Polls.dom.ts index 7566e54c41..7613c1b68e 100644 --- a/ts/types/Polls.dom.ts +++ b/ts/types/Polls.dom.ts @@ -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>; // PollTerminate schema (processed shape) // - targetTimestamp: required, positive int @@ -70,6 +69,7 @@ export type MessagePollVoteType = { optionIndexes: ReadonlyArray; voteCount: number; timestamp: number; + sendStateByConversationId?: SendStateByConversationId; }; export type PollMessageAttribute = { diff --git a/ts/util/handleMessageSend.preload.ts b/ts/util/handleMessageSend.preload.ts index 801f367c9c..e5d383f97d 100644 --- a/ts/util/handleMessageSend.preload.ts +++ b/ts/util/handleMessageSend.preload.ts @@ -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