From 2b99aed14d36d8469bf9587de65e4bbfdca161dc Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Mon, 10 Nov 2025 20:18:30 -0500 Subject: [PATCH] Add pending poll vote UI state Co-authored-by: Yash --- ts/components/SpinnerV2.dom.tsx | 5 +- .../TimelineMessage.dom.stories.tsx | 31 ++++ .../poll-message/PollMessageContents.dom.tsx | 145 ++++++++++++++---- ts/state/selectors/message.preload.ts | 47 +++++- ts/util/lint/exceptions.json | 8 + 5 files changed, 201 insertions(+), 35 deletions(-) diff --git a/ts/components/SpinnerV2.dom.tsx b/ts/components/SpinnerV2.dom.tsx index 7525ee03cc..571c795a4c 100644 --- a/ts/components/SpinnerV2.dom.tsx +++ b/ts/components/SpinnerV2.dom.tsx @@ -10,7 +10,7 @@ export type Props = { value?: number | 'indeterminate'; // default: 'indeterminate' min?: number; // default: 0 max?: number; // default: 1 - variant?: SpinnerVariant; + variant?: SpinnerVariant | SpinnerVariantStyles; ariaLabel?: string; marginRatio?: number; size: number; @@ -85,7 +85,8 @@ export function SpinnerV2({ ); const circumference = radius * 2 * Math.PI; - const { bg, fg } = SpinnerVariants[variant]; + const { bg, fg } = + typeof variant === 'string' ? SpinnerVariants[variant] : variant; const bgElem = ( ; }>, + pendingVoteDiff?: Map, terminatedAt?: number ) { const resolvedVotes = @@ -2041,6 +2042,7 @@ function createMockPollWithVotes( totalNumVotes, uniqueVoters: uniqueVoterIds.size, terminatedAt, + pendingVoteDiff, votes: votes?.map(v => ({ fromConversationId: v.fromId, optionIndexes: v.optionIndexes, @@ -2102,6 +2104,34 @@ PollWithVotes.args = { status: 'read', }; +export const PollWithPendingVotes = Template.bind({}); +PollWithPendingVotes.args = { + conversationType: 'group', + poll: createMockPollWithVotes( + 'Best day for the team meeting?', + ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], + false, + [ + { fromId: 'alice', optionIndexes: [0] }, + { fromId: 'user1', optionIndexes: [0] }, + { fromId: 'user2', optionIndexes: [0] }, + { fromId: 'bob', optionIndexes: [1] }, + { fromId: 'user3', optionIndexes: [1] }, + { fromId: 'charlie', optionIndexes: [2] }, + { fromId: 'user4', optionIndexes: [2] }, + { fromId: 'user5', optionIndexes: [2] }, + { fromId: 'user6', optionIndexes: [2] }, + { fromId: 'user7', optionIndexes: [2] }, + { fromId: 'me', optionIndexes: [3] }, + ], + new Map([ + [3, 'PENDING_UNVOTE'], + [1, 'PENDING_VOTE'], + ]) + ), + status: 'read', +}; + export const PollTerminated = Template.bind({}); PollTerminated.args = { conversationType: 'group', @@ -2122,6 +2152,7 @@ PollTerminated.args = { { fromId: 'user7', optionIndexes: [1] }, { fromId: 'user8', optionIndexes: [1] }, ], + undefined, Date.now() - 60000 ), status: 'read', diff --git a/ts/components/conversation/poll-message/PollMessageContents.dom.tsx b/ts/components/conversation/poll-message/PollMessageContents.dom.tsx index 07e2e6e568..af5e68dfba 100644 --- a/ts/components/conversation/poll-message/PollMessageContents.dom.tsx +++ b/ts/components/conversation/poll-message/PollMessageContents.dom.tsx @@ -1,15 +1,17 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { memo, useState } from 'react'; +import React, { memo, useState, useEffect, useRef } from 'react'; import { Checkbox } from 'radix-ui'; -import { tw } from '../../../axo/tw.dom.js'; +import { type TailwindStyles, tw } from '../../../axo/tw.dom.js'; import { AxoButton } from '../../../axo/AxoButton.dom.js'; import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js'; import type { DirectionType } from '../Message.dom.js'; import type { PollWithResolvedVotersType } from '../../../state/selectors/message.preload.js'; import type { LocalizerType } from '../../../types/Util.std.js'; import { PollVotesModal } from './PollVotesModal.dom.js'; +import { SpinnerV2 } from '../../SpinnerV2.dom.js'; +import { usePrevious } from '../../../hooks/usePrevious.std.js'; function VotedCheckmark({ isIncoming, @@ -41,39 +43,78 @@ type PollCheckboxProps = { checked: boolean; onCheckedChange: (nextChecked: boolean) => void; isIncoming: boolean; + isPending: boolean; }; const PollCheckbox = memo((props: PollCheckboxProps) => { - const { isIncoming } = props; + const { isIncoming, isPending, checked } = props; + + let bgColor: TailwindStyles; + let borderColor: TailwindStyles; + let strokeColor: TailwindStyles | undefined; + let checkmarkColor: TailwindStyles | undefined; + + if (isPending || !checked) { + bgColor = tw('bg-transparent'); + borderColor = isIncoming + ? tw('border-label-placeholder') + : tw('border-label-primary-on-color'); + strokeColor = isIncoming + ? tw('stroke-label-placeholder') + : tw('stroke-label-primary-on-color'); + checkmarkColor = isIncoming + ? tw('text-label-placeholder') + : tw('text-label-primary-on-color'); + } else { + bgColor = isIncoming + ? tw('bg-color-fill-primary') + : tw('bg-label-primary-on-color'); + borderColor = isIncoming + ? tw('border-color-fill-primary') + : tw('border-label-primary-on-color'); + strokeColor = isIncoming + ? tw('stroke-color-fill-primary') + : tw('stroke-label-primary-on-color'); + checkmarkColor = isIncoming + ? tw('text-label-primary-on-color') + : tw('text-color-fill-primary'); + } return ( - - + {isPending ? ( +
+ +
+ ) : null} + - -
-
+ + + + + ); }); @@ -92,6 +133,7 @@ export type PollMessageContentsProps = { canEndPoll?: boolean; }; +const DELAY_BEFORE_SHOWING_PENDING_ANIMATION = 500; export function PollMessageContents({ poll, direction, @@ -102,9 +144,34 @@ export function PollMessageContents({ canEndPoll, }: PollMessageContentsProps): JSX.Element { const [showVotesModal, setShowVotesModal] = useState(false); + const [isPending, setIsPending] = useState(false); + + const hasPendingVotes = poll.pendingVoteDiff && poll.pendingVoteDiff.size > 0; + const hadPendingVotesInLastRender = usePrevious(hasPendingVotes, undefined); + + const pendingCheckTimer = useRef(null); const isIncoming = direction === 'incoming'; const { totalNumVotes: totalVotes, uniqueVoters } = poll; + // Handle pending vote state changes + useEffect(() => { + if (!hasPendingVotes) { + // Vote completed, clear pending state + setIsPending(false); + clearTimeout(pendingCheckTimer.current ?? undefined); + pendingCheckTimer.current = null; + } else if (!hadPendingVotesInLastRender) { + pendingCheckTimer.current = setTimeout(() => { + setIsPending(true); + }, DELAY_BEFORE_SHOWING_PENDING_ANIMATION); + } + }, [hadPendingVotesInLastRender, hasPendingVotes]); + + useEffect(() => { + return () => { + clearTimeout(pendingCheckTimer.current ?? undefined); + }; + }, []); let pollStatusText: string; if (poll.terminatedAt) { @@ -115,10 +182,7 @@ export function PollMessageContents({ pollStatusText = i18n('icu:PollMessage--SelectOne'); } - async function handlePollOptionClicked( - index: number, - nextChecked: boolean - ): Promise { + function handlePollOptionClicked(index: number, nextChecked: boolean): void { const existingSelections = Array.from( poll.votesByOption .entries() @@ -127,6 +191,16 @@ export function PollMessageContents({ ); const optionIndexes = new Set(existingSelections); + if (poll.pendingVoteDiff) { + for (const [idx, pendingVoteOrUnvote] of poll.pendingVoteDiff.entries()) { + if (pendingVoteOrUnvote === 'PENDING_VOTE') { + optionIndexes.add(idx); + } else if (pendingVoteOrUnvote === 'PENDING_UNVOTE') { + optionIndexes.delete(idx); + } + } + } + if (nextChecked) { if (!poll.allowMultiple) { // Single-select: clear existing selections first @@ -174,6 +248,12 @@ export function PollMessageContents({ uniqueVoters > 0 ? (optionVotes / uniqueVoters) * 100 : 0; const weVotedForThis = (pollVoteEntries ?? []).some(v => v.isMe); + const pendingVoteOrUnvote = poll.pendingVoteDiff?.get(index); + const isVotePending = isPending && pendingVoteOrUnvote != null; + + const shouldShowCheckmark = isVotePending + ? pendingVoteOrUnvote === 'PENDING_VOTE' + : weVotedForThis; return ( // eslint-disable-next-line react/no-array-index-key @@ -183,11 +263,12 @@ export function PollMessageContents({ // creating 3px space above text. This aligns checkbox with text baseline.
handlePollOptionClicked(index, Boolean(next)) } isIncoming={isIncoming} + isPending={isVotePending} />
)} diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index f54ac1fc80..3e7b75b758 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -506,6 +506,7 @@ export type PollWithResolvedVotersType = PollMessageAttribute & { votesByOption: Map>; totalNumVotes: number; uniqueVoters: number; + pendingVoteDiff?: Map; }; const getPollForMessage = ( @@ -532,10 +533,53 @@ const getPollForMessage = ( }; } + let successfulVote: MessagePollVoteType | undefined; + let pendingVote: MessagePollVoteType | undefined; + + for (const vote of poll.votes) { + if (vote.fromConversationId === ourConversationId) { + if ( + vote.sendStateByConversationId && + Object.keys(vote.sendStateByConversationId).length > 0 + ) { + pendingVote = vote; + } else { + successfulVote = vote; + } + } + } + + // Compute diff between successful and pending vote + let pendingVoteDiff: + | Map + | undefined; + if (pendingVote) { + pendingVoteDiff = new Map(); + const successfulIndexes = new Set(successfulVote?.optionIndexes ?? []); + const pendingIndexes = new Set(pendingVote.optionIndexes); + + for (const index of pendingIndexes) { + if (!successfulIndexes.has(index)) { + pendingVoteDiff.set(index, 'PENDING_VOTE'); + } + } + + for (const index of successfulIndexes) { + if (!pendingIndexes.has(index)) { + pendingVoteDiff.set(index, 'PENDING_UNVOTE'); + } + } + } + + // Filter out pending votes from the votes we'll display + const votesToProcess = poll.votes.filter( + vote => !vote.sendStateByConversationId + ); + // 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) { + for (const vote of votesToProcess) { const existingVote = voteByFrom.get(vote.fromConversationId); if ( !existingVote || @@ -596,6 +640,7 @@ const getPollForMessage = ( votesByOption, totalNumVotes, uniqueVoters: uniqueVoterIds.size, + pendingVoteDiff, }; }; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index bda146483f..930f0ad39c 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2358,5 +2358,13 @@ "reasonCategory": "usageTrusted", "updated": "2025-11-02T17:27:24.705Z", "reasonDetail": "Map of refs for poll option inputs to manage focus" + }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/poll-message/PollMessageContents.dom.tsx", + "line": " const pendingCheckTimer = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-11-06T20:28:00.760Z", + "reasonDetail": "Ref for timer" } ]