11// Copyright 2025 Signal Messenger, LLC
22// SPDX-License-Identifier: AGPL-3.0-only
33
4- import React , { memo , useState } from 'react' ;
4+ import React , { memo , useState , useEffect , useRef } from 'react' ;
55import { Checkbox } from 'radix-ui' ;
6- import { tw } from '../../../axo/tw.dom.js' ;
6+ import { type TailwindStyles , tw } from '../../../axo/tw.dom.js' ;
77import { AxoButton } from '../../../axo/AxoButton.dom.js' ;
88import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js' ;
99import type { DirectionType } from '../Message.dom.js' ;
1010import type { PollWithResolvedVotersType } from '../../../state/selectors/message.preload.js' ;
1111import type { LocalizerType } from '../../../types/Util.std.js' ;
1212import { PollVotesModal } from './PollVotesModal.dom.js' ;
13+ import { SpinnerV2 } from '../../SpinnerV2.dom.js' ;
14+ import { usePrevious } from '../../../hooks/usePrevious.std.js' ;
1315
1416function VotedCheckmark ( {
1517 isIncoming,
@@ -41,39 +43,78 @@ type PollCheckboxProps = {
4143 checked : boolean ;
4244 onCheckedChange : ( nextChecked : boolean ) => void ;
4345 isIncoming : boolean ;
46+ isPending : boolean ;
4447} ;
4548
4649const PollCheckbox = memo ( ( props : PollCheckboxProps ) => {
47- const { isIncoming } = props ;
50+ const { isIncoming, isPending, checked } = props ;
51+
52+ let bgColor : TailwindStyles ;
53+ let borderColor : TailwindStyles ;
54+ let strokeColor : TailwindStyles | undefined ;
55+ let checkmarkColor : TailwindStyles | undefined ;
56+
57+ if ( isPending || ! checked ) {
58+ bgColor = tw ( 'bg-transparent' ) ;
59+ borderColor = isIncoming
60+ ? tw ( 'border-label-placeholder' )
61+ : tw ( 'border-label-primary-on-color' ) ;
62+ strokeColor = isIncoming
63+ ? tw ( 'stroke-label-placeholder' )
64+ : tw ( 'stroke-label-primary-on-color' ) ;
65+ checkmarkColor = isIncoming
66+ ? tw ( 'text-label-placeholder' )
67+ : tw ( 'text-label-primary-on-color' ) ;
68+ } else {
69+ bgColor = isIncoming
70+ ? tw ( 'bg-color-fill-primary' )
71+ : tw ( 'bg-label-primary-on-color' ) ;
72+ borderColor = isIncoming
73+ ? tw ( 'border-color-fill-primary' )
74+ : tw ( 'border-label-primary-on-color' ) ;
75+ strokeColor = isIncoming
76+ ? tw ( 'stroke-color-fill-primary' )
77+ : tw ( 'stroke-label-primary-on-color' ) ;
78+ checkmarkColor = isIncoming
79+ ? tw ( 'text-label-primary-on-color' )
80+ : tw ( 'text-color-fill-primary' ) ;
81+ }
4882
4983 return (
50- < Checkbox . Root
51- checked = { props . checked }
52- onCheckedChange = { props . onCheckedChange }
53- className = { tw (
54- 'flex size-6 items-center justify-center rounded-full' ,
55- 'border-[1.5px]' ,
56- 'outline-0 outline-border-focused focused:outline-[2.5px]' ,
57- 'overflow-hidden' ,
58- // Unchecked states
59- 'data-[state=unchecked]:bg-transparent' ,
60- isIncoming
61- ? 'data-[state=unchecked]:border-label-placeholder'
62- : 'data-[state=unchecked]:border-label-primary-on-color' ,
63- // Checked states
64- isIncoming
65- ? 'data-[state=checked]:border-color-fill-primary data-[state=checked]:bg-color-fill-primary'
66- : 'data-[state=checked]:border-label-primary-on-color data-[state=checked]:bg-label-primary-on-color'
67- ) }
68- >
69- < Checkbox . Indicator
84+ < >
85+ { isPending ? (
86+ < div className = { tw ( 'pointer-events-none absolute' ) } >
87+ < SpinnerV2
88+ value = "indeterminate"
89+ size = { 24 }
90+ strokeWidth = { 1.5 }
91+ marginRatio = { 1 }
92+ variant = { {
93+ bg : tw ( 'stroke-none' ) ,
94+ fg : strokeColor ,
95+ } }
96+ />
97+ </ div >
98+ ) : null }
99+ < Checkbox . Root
100+ checked = { props . checked }
101+ onCheckedChange = { props . onCheckedChange }
70102 className = { tw (
71- isIncoming ? 'text-label-primary-on-color' : 'text-color-fill-primary'
103+ 'flex size-6 items-center justify-center rounded-full' ,
104+ isPending ? '' : 'border-[1.5px]' ,
105+ 'outline-0 outline-border-focused focused:outline-[2.5px]' ,
106+ 'overflow-hidden' ,
107+ bgColor ,
108+ borderColor
72109 ) }
73110 >
74- < AxoSymbol . Icon symbol = "check" size = { 16 } label = { null } />
75- </ Checkbox . Indicator >
76- </ Checkbox . Root >
111+ < Checkbox . Indicator
112+ className = { tw ( checkmarkColor , 'flex items-center justify-center' ) }
113+ >
114+ < AxoSymbol . Icon symbol = "check" size = { 16 } label = { null } />
115+ </ Checkbox . Indicator >
116+ </ Checkbox . Root >
117+ </ >
77118 ) ;
78119} ) ;
79120
@@ -92,6 +133,7 @@ export type PollMessageContentsProps = {
92133 canEndPoll ?: boolean ;
93134} ;
94135
136+ const DELAY_BEFORE_SHOWING_PENDING_ANIMATION = 500 ;
95137export function PollMessageContents ( {
96138 poll,
97139 direction,
@@ -102,9 +144,34 @@ export function PollMessageContents({
102144 canEndPoll,
103145} : PollMessageContentsProps ) : JSX . Element {
104146 const [ showVotesModal , setShowVotesModal ] = useState ( false ) ;
147+ const [ isPending , setIsPending ] = useState ( false ) ;
148+
149+ const hasPendingVotes = poll . pendingVoteDiff && poll . pendingVoteDiff . size > 0 ;
150+ const hadPendingVotesInLastRender = usePrevious ( hasPendingVotes , undefined ) ;
151+
152+ const pendingCheckTimer = useRef < NodeJS . Timeout | null > ( null ) ;
105153 const isIncoming = direction === 'incoming' ;
106154
107155 const { totalNumVotes : totalVotes , uniqueVoters } = poll ;
156+ // Handle pending vote state changes
157+ useEffect ( ( ) => {
158+ if ( ! hasPendingVotes ) {
159+ // Vote completed, clear pending state
160+ setIsPending ( false ) ;
161+ clearTimeout ( pendingCheckTimer . current ?? undefined ) ;
162+ pendingCheckTimer . current = null ;
163+ } else if ( ! hadPendingVotesInLastRender ) {
164+ pendingCheckTimer . current = setTimeout ( ( ) => {
165+ setIsPending ( true ) ;
166+ } , DELAY_BEFORE_SHOWING_PENDING_ANIMATION ) ;
167+ }
168+ } , [ hadPendingVotesInLastRender , hasPendingVotes ] ) ;
169+
170+ useEffect ( ( ) => {
171+ return ( ) => {
172+ clearTimeout ( pendingCheckTimer . current ?? undefined ) ;
173+ } ;
174+ } , [ ] ) ;
108175
109176 let pollStatusText : string ;
110177 if ( poll . terminatedAt ) {
@@ -115,10 +182,7 @@ export function PollMessageContents({
115182 pollStatusText = i18n ( 'icu:PollMessage--SelectOne' ) ;
116183 }
117184
118- async function handlePollOptionClicked (
119- index : number ,
120- nextChecked : boolean
121- ) : Promise < void > {
185+ function handlePollOptionClicked ( index : number , nextChecked : boolean ) : void {
122186 const existingSelections = Array . from (
123187 poll . votesByOption
124188 . entries ( )
@@ -127,6 +191,16 @@ export function PollMessageContents({
127191 ) ;
128192 const optionIndexes = new Set < number > ( existingSelections ) ;
129193
194+ if ( poll . pendingVoteDiff ) {
195+ for ( const [ idx , pendingVoteOrUnvote ] of poll . pendingVoteDiff . entries ( ) ) {
196+ if ( pendingVoteOrUnvote === 'PENDING_VOTE' ) {
197+ optionIndexes . add ( idx ) ;
198+ } else if ( pendingVoteOrUnvote === 'PENDING_UNVOTE' ) {
199+ optionIndexes . delete ( idx ) ;
200+ }
201+ }
202+ }
203+
130204 if ( nextChecked ) {
131205 if ( ! poll . allowMultiple ) {
132206 // Single-select: clear existing selections first
@@ -174,6 +248,12 @@ export function PollMessageContents({
174248 uniqueVoters > 0 ? ( optionVotes / uniqueVoters ) * 100 : 0 ;
175249
176250 const weVotedForThis = ( pollVoteEntries ?? [ ] ) . some ( v => v . isMe ) ;
251+ const pendingVoteOrUnvote = poll . pendingVoteDiff ?. get ( index ) ;
252+ const isVotePending = isPending && pendingVoteOrUnvote != null ;
253+
254+ const shouldShowCheckmark = isVotePending
255+ ? pendingVoteOrUnvote === 'PENDING_VOTE'
256+ : weVotedForThis ;
177257
178258 return (
179259 // eslint-disable-next-line react/no-array-index-key
@@ -183,11 +263,12 @@ export function PollMessageContents({
183263 // creating 3px space above text. This aligns checkbox with text baseline.
184264 < div className = { tw ( 'mt-[3px] self-start' ) } >
185265 < PollCheckbox
186- checked = { weVotedForThis }
266+ checked = { shouldShowCheckmark }
187267 onCheckedChange = { next =>
188268 handlePollOptionClicked ( index , Boolean ( next ) )
189269 }
190270 isIncoming = { isIncoming }
271+ isPending = { isVotePending }
191272 />
192273 </ div >
193274 ) }
0 commit comments