Add pending poll vote UI state

Co-authored-by: Yash <yash@signal.org>
This commit is contained in:
trevor-signal
2025-11-10 20:18:30 -05:00
committed by GitHub
parent 3dbab74378
commit 2b99aed14d
5 changed files with 201 additions and 35 deletions

View File

@@ -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 = (
<circle

View File

@@ -1992,6 +1992,7 @@ function createMockPollWithVotes(
fromId: string;
optionIndexes: Array<number>;
}>,
pendingVoteDiff?: Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>,
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',

View File

@@ -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 ? (
<div className={tw('pointer-events-none absolute')}>
<SpinnerV2
value="indeterminate"
size={24}
strokeWidth={1.5}
marginRatio={1}
variant={{
bg: tw('stroke-none'),
fg: strokeColor,
}}
/>
</div>
) : null}
<Checkbox.Root
checked={props.checked}
onCheckedChange={props.onCheckedChange}
className={tw(
'flex size-6 items-center justify-center rounded-full',
'border-[1.5px]',
isPending ? '' : 'border-[1.5px]',
'outline-0 outline-border-focused focused:outline-[2.5px]',
'overflow-hidden',
// Unchecked states
'data-[state=unchecked]:bg-transparent',
isIncoming
? 'data-[state=unchecked]:border-label-placeholder'
: 'data-[state=unchecked]:border-label-primary-on-color',
// Checked states
isIncoming
? 'data-[state=checked]:border-color-fill-primary data-[state=checked]:bg-color-fill-primary'
: 'data-[state=checked]:border-label-primary-on-color data-[state=checked]:bg-label-primary-on-color'
bgColor,
borderColor
)}
>
<Checkbox.Indicator
className={tw(
isIncoming ? 'text-label-primary-on-color' : 'text-color-fill-primary'
)}
className={tw(checkmarkColor, 'flex items-center justify-center')}
>
<AxoSymbol.Icon symbol="check" size={16} label={null} />
</Checkbox.Indicator>
</Checkbox.Root>
</>
);
});
@@ -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<NodeJS.Timeout | null>(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<void> {
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<number>(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.
<div className={tw('mt-[3px] self-start')}>
<PollCheckbox
checked={weVotedForThis}
checked={shouldShowCheckmark}
onCheckedChange={next =>
handlePollOptionClicked(index, Boolean(next))
}
isIncoming={isIncoming}
isPending={isVotePending}
/>
</div>
)}

View File

@@ -506,6 +506,7 @@ export type PollWithResolvedVotersType = PollMessageAttribute & {
votesByOption: Map<number, ReadonlyArray<PollVoteWithUserType>>;
totalNumVotes: number;
uniqueVoters: number;
pendingVoteDiff?: Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>;
};
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<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>
| 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<string, MessagePollVoteType>();
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,
};
};

View File

@@ -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<NodeJS.Timeout | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-11-06T20:28:00.760Z",
"reasonDetail": "Ref for timer"
}
]