Skip to content

Commit 2b99aed

Browse files
Add pending poll vote UI state
Co-authored-by: Yash <[email protected]>
1 parent 3dbab74 commit 2b99aed

File tree

5 files changed

+201
-35
lines changed

5 files changed

+201
-35
lines changed

ts/components/SpinnerV2.dom.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export type Props = {
1010
value?: number | 'indeterminate'; // default: 'indeterminate'
1111
min?: number; // default: 0
1212
max?: number; // default: 1
13-
variant?: SpinnerVariant;
13+
variant?: SpinnerVariant | SpinnerVariantStyles;
1414
ariaLabel?: string;
1515
marginRatio?: number;
1616
size: number;
@@ -85,7 +85,8 @@ export function SpinnerV2({
8585
);
8686
const circumference = radius * 2 * Math.PI;
8787

88-
const { bg, fg } = SpinnerVariants[variant];
88+
const { bg, fg } =
89+
typeof variant === 'string' ? SpinnerVariants[variant] : variant;
8990

9091
const bgElem = (
9192
<circle

ts/components/conversation/TimelineMessage.dom.stories.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1992,6 +1992,7 @@ function createMockPollWithVotes(
19921992
fromId: string;
19931993
optionIndexes: Array<number>;
19941994
}>,
1995+
pendingVoteDiff?: Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>,
19951996
terminatedAt?: number
19961997
) {
19971998
const resolvedVotes =
@@ -2041,6 +2042,7 @@ function createMockPollWithVotes(
20412042
totalNumVotes,
20422043
uniqueVoters: uniqueVoterIds.size,
20432044
terminatedAt,
2045+
pendingVoteDiff,
20442046
votes: votes?.map(v => ({
20452047
fromConversationId: v.fromId,
20462048
optionIndexes: v.optionIndexes,
@@ -2102,6 +2104,34 @@ PollWithVotes.args = {
21022104
status: 'read',
21032105
};
21042106

2107+
export const PollWithPendingVotes = Template.bind({});
2108+
PollWithPendingVotes.args = {
2109+
conversationType: 'group',
2110+
poll: createMockPollWithVotes(
2111+
'Best day for the team meeting?',
2112+
['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
2113+
false,
2114+
[
2115+
{ fromId: 'alice', optionIndexes: [0] },
2116+
{ fromId: 'user1', optionIndexes: [0] },
2117+
{ fromId: 'user2', optionIndexes: [0] },
2118+
{ fromId: 'bob', optionIndexes: [1] },
2119+
{ fromId: 'user3', optionIndexes: [1] },
2120+
{ fromId: 'charlie', optionIndexes: [2] },
2121+
{ fromId: 'user4', optionIndexes: [2] },
2122+
{ fromId: 'user5', optionIndexes: [2] },
2123+
{ fromId: 'user6', optionIndexes: [2] },
2124+
{ fromId: 'user7', optionIndexes: [2] },
2125+
{ fromId: 'me', optionIndexes: [3] },
2126+
],
2127+
new Map([
2128+
[3, 'PENDING_UNVOTE'],
2129+
[1, 'PENDING_VOTE'],
2130+
])
2131+
),
2132+
status: 'read',
2133+
};
2134+
21052135
export const PollTerminated = Template.bind({});
21062136
PollTerminated.args = {
21072137
conversationType: 'group',
@@ -2122,6 +2152,7 @@ PollTerminated.args = {
21222152
{ fromId: 'user7', optionIndexes: [1] },
21232153
{ fromId: 'user8', optionIndexes: [1] },
21242154
],
2155+
undefined,
21252156
Date.now() - 60000
21262157
),
21272158
status: 'read',

ts/components/conversation/poll-message/PollMessageContents.dom.tsx

Lines changed: 113 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
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';
55
import { Checkbox } from 'radix-ui';
6-
import { tw } from '../../../axo/tw.dom.js';
6+
import { type TailwindStyles, tw } from '../../../axo/tw.dom.js';
77
import { AxoButton } from '../../../axo/AxoButton.dom.js';
88
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
99
import type { DirectionType } from '../Message.dom.js';
1010
import type { PollWithResolvedVotersType } from '../../../state/selectors/message.preload.js';
1111
import type { LocalizerType } from '../../../types/Util.std.js';
1212
import { PollVotesModal } from './PollVotesModal.dom.js';
13+
import { SpinnerV2 } from '../../SpinnerV2.dom.js';
14+
import { usePrevious } from '../../../hooks/usePrevious.std.js';
1315

1416
function 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

4649
const 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;
95137
export 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
)}

ts/state/selectors/message.preload.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ export type PollWithResolvedVotersType = PollMessageAttribute & {
506506
votesByOption: Map<number, ReadonlyArray<PollVoteWithUserType>>;
507507
totalNumVotes: number;
508508
uniqueVoters: number;
509+
pendingVoteDiff?: Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>;
509510
};
510511

511512
const getPollForMessage = (
@@ -532,10 +533,53 @@ const getPollForMessage = (
532533
};
533534
}
534535

536+
let successfulVote: MessagePollVoteType | undefined;
537+
let pendingVote: MessagePollVoteType | undefined;
538+
539+
for (const vote of poll.votes) {
540+
if (vote.fromConversationId === ourConversationId) {
541+
if (
542+
vote.sendStateByConversationId &&
543+
Object.keys(vote.sendStateByConversationId).length > 0
544+
) {
545+
pendingVote = vote;
546+
} else {
547+
successfulVote = vote;
548+
}
549+
}
550+
}
551+
552+
// Compute diff between successful and pending vote
553+
let pendingVoteDiff:
554+
| Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>
555+
| undefined;
556+
if (pendingVote) {
557+
pendingVoteDiff = new Map();
558+
const successfulIndexes = new Set(successfulVote?.optionIndexes ?? []);
559+
const pendingIndexes = new Set(pendingVote.optionIndexes);
560+
561+
for (const index of pendingIndexes) {
562+
if (!successfulIndexes.has(index)) {
563+
pendingVoteDiff.set(index, 'PENDING_VOTE');
564+
}
565+
}
566+
567+
for (const index of successfulIndexes) {
568+
if (!pendingIndexes.has(index)) {
569+
pendingVoteDiff.set(index, 'PENDING_UNVOTE');
570+
}
571+
}
572+
}
573+
574+
// Filter out pending votes from the votes we'll display
575+
const votesToProcess = poll.votes.filter(
576+
vote => !vote.sendStateByConversationId
577+
);
578+
535579
// Deduplicate votes by sender - keep only the newest vote per sender
536580
// (highest voteCount, or newest timestamp if voteCount is equal)
537581
const voteByFrom = new Map<string, MessagePollVoteType>();
538-
for (const vote of poll.votes) {
582+
for (const vote of votesToProcess) {
539583
const existingVote = voteByFrom.get(vote.fromConversationId);
540584
if (
541585
!existingVote ||
@@ -596,6 +640,7 @@ const getPollForMessage = (
596640
votesByOption,
597641
totalNumVotes,
598642
uniqueVoters: uniqueVoterIds.size,
643+
pendingVoteDiff,
599644
};
600645
};
601646

ts/util/lint/exceptions.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2358,5 +2358,13 @@
23582358
"reasonCategory": "usageTrusted",
23592359
"updated": "2025-11-02T17:27:24.705Z",
23602360
"reasonDetail": "Map of refs for poll option inputs to manage focus"
2361+
},
2362+
{
2363+
"rule": "React-useRef",
2364+
"path": "ts/components/conversation/poll-message/PollMessageContents.dom.tsx",
2365+
"line": " const pendingCheckTimer = useRef<NodeJS.Timeout | null>(null);",
2366+
"reasonCategory": "usageTrusted",
2367+
"updated": "2025-11-06T20:28:00.760Z",
2368+
"reasonDetail": "Ref for timer"
23612369
}
23622370
]

0 commit comments

Comments
 (0)