Polls animations

Co-authored-by: yash-signal <yash@signal.org>
This commit is contained in:
automated-signal
2025-12-11 17:14:54 -06:00
committed by GitHub
parent 0915d48a59
commit 2087357541
2 changed files with 380 additions and 72 deletions

View File

@@ -6,6 +6,7 @@ import lodash from 'lodash';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import type { Meta, StoryFn } from '@storybook/react'; import type { Meta, StoryFn } from '@storybook/react';
import { tw } from '../../axo/tw.dom.js';
import { SignalService } from '../../protobuf/index.std.js'; import { SignalService } from '../../protobuf/index.std.js';
import { ConversationColors } from '../../types/Colors.std.js'; import { ConversationColors } from '../../types/Colors.std.js';
@@ -43,6 +44,7 @@ import { ThemeType } from '../../types/Util.std.js';
import { BadgeCategory } from '../../badges/BadgeCategory.std.js'; import { BadgeCategory } from '../../badges/BadgeCategory.std.js';
import { PaymentEventKind } from '../../types/Payment.std.js'; import { PaymentEventKind } from '../../types/Payment.std.js';
import type { RenderAudioAttachmentProps } from '../../state/smart/renderAudioAttachment.preload.js'; import type { RenderAudioAttachmentProps } from '../../state/smart/renderAudioAttachment.preload.js';
import type { PollVoteWithUserType } from '../../state/selectors/message.preload.js';
const { isBoolean, noop } = lodash; const { isBoolean, noop } = lodash;
@@ -314,7 +316,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
saveAttachments: action('saveAttachments'), saveAttachments: action('saveAttachments'),
setQuoteByMessageId: action('setQuoteByMessageId'), setQuoteByMessageId: action('setQuoteByMessageId'),
retryMessageSend: action('retryMessageSend'), retryMessageSend: action('retryMessageSend'),
sendPollVote: action('sendPollVote'), sendPollVote: overrideProps.sendPollVote ?? action('sendPollVote'),
copyMessageText: action('copyMessageText'), copyMessageText: action('copyMessageText'),
retryDeleteForEveryone: action('retryDeleteForEveryone'), retryDeleteForEveryone: action('retryDeleteForEveryone'),
scrollToQuotedMessage: action('scrollToQuotedMessage'), scrollToQuotedMessage: action('scrollToQuotedMessage'),
@@ -1991,7 +1993,99 @@ AudioWithPendingAttachment.args = {
// Poll Messages // Poll Messages
function createMockPollWithVotes( function getStableVoter(optionIndex: number, voterIndex: number) {
const name = `Voter ${optionIndex * 100 + voterIndex + 1}`;
return {
acceptedMessageRequest: true,
avatarUrl: undefined,
badges: [],
color: 'A100' as const,
id: `stable-voter-${optionIndex}-${voterIndex}`,
isMe: false,
name,
phoneNumber: undefined,
profileName: undefined,
sharedGroupNames: [],
title: name,
};
}
function createMockPollWithVoteCounts(
question: string,
options: Array<string>,
otherVoteCounts: Map<number, number>,
allowMultiple: boolean,
myVotes: Set<number>,
pendingVoteDiff?: Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>
) {
const votesByOption = new Map<number, Array<PollVoteWithUserType>>();
let totalNumVotes = 0;
const uniqueVoterIds = new Set<string>();
// Add other voters
otherVoteCounts.forEach((count, optionIndex) => {
if (count > 0) {
const voters = [];
for (let i = 0; i < count; i += 1) {
const from = getStableVoter(optionIndex, i);
uniqueVoterIds.add(from.id);
voters.push({
optionIndexes: [optionIndex],
timestamp: Date.now() - (optionIndex * 100 + i) * 1000,
isMe: false,
from,
});
}
votesByOption.set(optionIndex, voters);
totalNumVotes += count;
}
});
// Add my votes if present
if (myVotes.size > 0) {
uniqueVoterIds.add('me');
const myVoteIndexes = Array.from(myVotes);
for (const voteIndex of myVoteIndexes) {
const existingVoters = votesByOption.get(voteIndex) ?? [];
votesByOption.set(voteIndex, [
...existingVoters,
{
optionIndexes: myVoteIndexes,
timestamp: Date.now(),
isMe: true,
from: {
acceptedMessageRequest: true,
avatarUrl: undefined,
badges: [],
color: 'A100' as const,
id: 'me',
isMe: true,
name: 'You',
phoneNumber: undefined,
profileName: undefined,
sharedGroupNames: [],
title: 'You',
},
},
]);
totalNumVotes += 1;
}
}
return {
question,
options,
allowMultiple,
votesByOption,
totalNumVotes,
uniqueVoters: uniqueVoterIds.size,
pendingVoteDiff,
};
}
function createMockPollWithVoters(
question: string, question: string,
options: Array<string>, options: Array<string>,
allowMultiple: boolean, allowMultiple: boolean,
@@ -2090,7 +2184,7 @@ PollMultipleChoice.args = {
export const PollWithVotes = Template.bind({}); export const PollWithVotes = Template.bind({});
PollWithVotes.args = { PollWithVotes.args = {
conversationType: 'group', conversationType: 'group',
poll: createMockPollWithVotes( poll: createMockPollWithVoters(
'Best day for the team meeting?', 'Best day for the team meeting?',
['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
false, false,
@@ -2114,7 +2208,7 @@ PollWithVotes.args = {
export const PollWithPendingVotes = Template.bind({}); export const PollWithPendingVotes = Template.bind({});
PollWithPendingVotes.args = { PollWithPendingVotes.args = {
conversationType: 'group', conversationType: 'group',
poll: createMockPollWithVotes( poll: createMockPollWithVoters(
'Best day for the team meeting?', 'Best day for the team meeting?',
['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
false, false,
@@ -2142,7 +2236,7 @@ PollWithPendingVotes.args = {
export const PollTerminated = Template.bind({}); export const PollTerminated = Template.bind({});
PollTerminated.args = { PollTerminated.args = {
conversationType: 'group', conversationType: 'group',
poll: createMockPollWithVotes( poll: createMockPollWithVoters(
'Quick poll: Coffee or tea?', 'Quick poll: Coffee or tea?',
['Coffee ☕', 'Tea 🍵'], ['Coffee ☕', 'Tea 🍵'],
false, false,
@@ -2168,7 +2262,7 @@ PollTerminated.args = {
export const PollLongText = Template.bind({}); export const PollLongText = Template.bind({});
PollLongText.args = { PollLongText.args = {
conversationType: 'group', conversationType: 'group',
poll: createMockPollWithVotes( poll: createMockPollWithVoters(
'Given the current situation with remote work becoming more prevalent, what would be your preferred working arrangement for the future once everything stabilizes?', 'Given the current situation with remote work becoming more prevalent, what would be your preferred working arrangement for the future once everything stabilizes?',
[ [
'Fully remote with no requirement to come to office except for special team events or emergencies', // 96 chars 'Fully remote with no requirement to come to office except for special team events or emergencies', // 96 chars
@@ -2193,7 +2287,7 @@ PollLongText.args = {
export const PollMultipleChoiceWithVotes = Template.bind({}); export const PollMultipleChoiceWithVotes = Template.bind({});
PollMultipleChoiceWithVotes.args = { PollMultipleChoiceWithVotes.args = {
conversationType: 'group', conversationType: 'group',
poll: createMockPollWithVotes( poll: createMockPollWithVoters(
'Which toppings do you want on the pizza?', 'Which toppings do you want on the pizza?',
[ [
'Pepperoni', 'Pepperoni',
@@ -2215,6 +2309,178 @@ PollMultipleChoiceWithVotes.args = {
status: 'read', status: 'read',
}; };
const POLL_ANIMATION_OPTIONS = ['Pizza', 'Sushi', 'Tacos', 'Salad'];
const BAD_NETWORK_DELAY_MS = 5000;
export function PollAnimationPlayground(): JSX.Element {
const [otherVoteCounts, setOtherVoteCounts] = React.useState<
Map<number, number>
>(() => new Map(POLL_ANIMATION_OPTIONS.map((_, i) => [i, 0])));
const [myVotes, setMyVotes] = React.useState<Set<number>>(() => new Set());
// Pending state for my vote (only used with bad network)
const [pendingVoteDiff, setPendingVoteDiff] = React.useState<
Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>
>(() => new Map());
const [badNetwork, setBadNetwork] = React.useState(false);
const [allowMultiple, setAllowMultiple] = React.useState(false);
const handleSendPollVote = React.useCallback(
(params: { messageId: string; optionIndexes: ReadonlyArray<number> }) => {
const newVotes = new Set(params.optionIndexes);
if (badNetwork) {
// Calculate pending diff: difference between committed state (myVotes)
// and requested state (newVotes)
const nextPendingDiff = new Map<
number,
'PENDING_VOTE' | 'PENDING_UNVOTE'
>();
// Options being added (in newVotes but not in myVotes)
for (const idx of newVotes) {
if (!myVotes.has(idx)) {
nextPendingDiff.set(idx, 'PENDING_VOTE');
}
}
// Options being removed (in myVotes but not in newVotes)
for (const idx of myVotes) {
if (!newVotes.has(idx)) {
nextPendingDiff.set(idx, 'PENDING_UNVOTE');
}
}
setPendingVoteDiff(nextPendingDiff);
// After delay, clear pending and apply votes
setTimeout(() => {
setPendingVoteDiff(new Map());
setMyVotes(newVotes);
}, BAD_NETWORK_DELAY_MS);
} else {
setMyVotes(newVotes);
}
},
[badNetwork, myVotes]
);
// Increment other voters (instant, no pending)
const incrementOther = (index: number) => {
setOtherVoteCounts(prev => {
const next = new Map(prev);
next.set(index, (prev.get(index) ?? 0) + 1);
return next;
});
};
// Decrement other voters (instant, no pending)
const decrementOther = (index: number) => {
setOtherVoteCounts(prev => {
const next = new Map(prev);
const current = prev.get(index) ?? 0;
if (current > 0) {
next.set(index, current - 1);
}
return next;
});
};
const reset = () => {
setOtherVoteCounts(new Map(POLL_ANIMATION_OPTIONS.map((_, i) => [i, 0])));
setMyVotes(new Set());
setPendingVoteDiff(new Map());
};
const poll = createMockPollWithVoteCounts(
'What should we have for lunch?',
POLL_ANIMATION_OPTIONS,
otherVoteCounts,
allowMultiple,
myVotes,
pendingVoteDiff.size > 0 ? pendingVoteDiff : undefined
);
const props = createProps({
conversationType: 'group',
poll,
status: 'sent',
sendPollVote: handleSendPollVote,
});
return (
<div>
<TimelineMessage {...props} />
<div
className={tw(
'mt-6 max-w-[300px] rounded-lg border border-solid border-label-primary p-4'
)}
>
<label className={tw('mb-2 flex cursor-pointer items-center gap-2')}>
<input
type="checkbox"
checked={allowMultiple}
onChange={e => {
setAllowMultiple(e.target.checked);
// Clear votes when changing mode to avoid invalid state
setMyVotes(new Set());
setPendingVoteDiff(new Map());
}}
/>
<span>Allow Multiple Votes</span>
</label>
<label className={tw('mb-4 flex cursor-pointer items-center gap-2')}>
<input
type="checkbox"
checked={badNetwork}
onChange={e => setBadNetwork(e.target.checked)}
/>
<span>Bad Network (5s delay for your vote)</span>
</label>
<div className={tw('mb-3 type-body-medium font-semibold')}>
Other Voters
</div>
{POLL_ANIMATION_OPTIONS.map((option, index) => (
<div
key={option}
className={tw('mb-2 flex items-center justify-between')}
>
<span className={tw('flex-1')}>{option}</span>
<div className={tw('flex items-center gap-2')}>
<button
type="button"
onClick={() => decrementOther(index)}
className={tw('size-7 cursor-pointer type-body-large')}
>
-
</button>
<span className={tw('min-w-5 text-center')}>
{otherVoteCounts.get(index) ?? 0}
</span>
<button
type="button"
onClick={() => incrementOther(index)}
className={tw('size-7 cursor-pointer type-body-large')}
>
+
</button>
</div>
</div>
))}
<button
type="button"
onClick={reset}
className={tw('mt-2 cursor-pointer px-3 py-1.5')}
>
Reset All
</button>
</div>
</div>
);
}
export const OtherFileType = Template.bind({}); export const OtherFileType = Template.bind({});
OtherFileType.args = { OtherFileType.args = {
attachments: [ attachments: [

View File

@@ -3,6 +3,7 @@
import React, { memo, useState, useEffect, useRef } from 'react'; import React, { memo, useState, useEffect, useRef } from 'react';
import { Checkbox } from 'radix-ui'; import { Checkbox } from 'radix-ui';
import { AnimatePresence, motion } from 'framer-motion';
import { type TailwindStyles, tw } from '../../../axo/tw.dom.js'; import { type TailwindStyles, tw } from '../../../axo/tw.dom.js';
import { AxoButton } from '../../../axo/AxoButton.dom.js'; import { AxoButton } from '../../../axo/AxoButton.dom.js';
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js'; import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
@@ -83,20 +84,28 @@ const PollCheckbox = memo((props: PollCheckboxProps) => {
return ( return (
<> <>
{isPending ? ( <AnimatePresence>
<div className={tw('pointer-events-none absolute')}> {isPending && (
<SpinnerV2 <motion.div
value="indeterminate" initial={{ opacity: 0 }}
size={24} animate={{ opacity: 1 }}
strokeWidth={1.5} exit={{ opacity: 0 }}
marginRatio={1} transition={{ duration: 0.25 }}
variant={{ className={tw('pointer-events-none absolute')}
bg: tw('stroke-none'), >
fg: strokeColor, <SpinnerV2
}} value="indeterminate"
/> size={24}
</div> strokeWidth={1.5}
) : null} marginRatio={1}
variant={{
bg: tw('stroke-none'),
fg: strokeColor,
}}
/>
</motion.div>
)}
</AnimatePresence>
<Checkbox.Root <Checkbox.Root
checked={props.checked} checked={props.checked}
onCheckedChange={props.onCheckedChange} onCheckedChange={props.onCheckedChange}
@@ -105,14 +114,25 @@ const PollCheckbox = memo((props: PollCheckboxProps) => {
isPending ? '' : 'border-[1.5px]', isPending ? '' : 'border-[1.5px]',
'outline-0 outline-border-focused focused:outline-[2.5px]', 'outline-0 outline-border-focused focused:outline-[2.5px]',
'overflow-hidden', 'overflow-hidden',
'transition-colors duration-250',
bgColor, bgColor,
borderColor borderColor
)} )}
> >
<Checkbox.Indicator <Checkbox.Indicator forceMount asChild>
className={tw(checkmarkColor, 'flex items-center justify-center')} <motion.div
> initial={false}
<AxoSymbol.Icon symbol="check" size={16} label={null} /> animate={{ opacity: checked ? 1 : 0 }}
transition={{ duration: 0.25 }}
className={tw(
checkmarkColor,
'flex items-center justify-center',
// Animate color so it doesn't instantly switch to grey during fade-out
'transition-colors duration-250'
)}
>
<AxoSymbol.Icon symbol="check" size={16} label={null} />
</motion.div>
</Checkbox.Indicator> </Checkbox.Indicator>
</Checkbox.Root> </Checkbox.Root>
</> </>
@@ -281,24 +301,28 @@ export function PollMessageContents({
<span className={tw('min-w-0 type-body-large break-words')}> <span className={tw('min-w-0 type-body-large break-words')}>
<UserText text={option} /> <UserText text={option} />
</span> </span>
{totalVotes > 0 && ( <div
<div className={tw('flex shrink-0 items-center gap-1')}> className={tw(
{poll.terminatedAt != null && weVotedForThis && ( 'flex shrink-0 items-center gap-1',
<VotedCheckmark isIncoming={isIncoming} i18n={i18n} /> 'transition-opacity duration-250',
totalVotes > 0 ? 'opacity-100' : 'invisible opacity-0'
)}
>
{poll.terminatedAt != null && weVotedForThis && (
<VotedCheckmark isIncoming={isIncoming} i18n={i18n} />
)}
<span
className={tw(
'type-body-medium',
isIncoming
? 'text-label-secondary'
: 'text-label-secondary-on-color'
)} )}
<span data-testid={`poll-option-${index}-votes-${optionVotes}`}
className={tw( >
'type-body-medium', {optionVotes}
isIncoming </span>
? 'text-label-secondary' </div>
: 'text-label-secondary-on-color'
)}
data-testid={`poll-option-${index}-votes-${optionVotes}`}
>
{optionVotes}
</span>
</div>
)}
</div> </div>
<div <div
@@ -314,17 +338,16 @@ export function PollMessageContents({
: 'bg-message-fill-outgoing-tertiary' : 'bg-message-fill-outgoing-tertiary'
)} )}
/> />
{percentage > 0 && ( <div
<div className={tw(
className={tw( 'absolute inset-y-0 start-0 rounded-s-full',
'absolute inset-y-0 start-0 rounded-s-full', 'transition-[width] duration-250 motion-reduce:transition-none',
isIncoming isIncoming
? 'bg-color-fill-primary' ? 'bg-color-fill-primary'
: 'bg-label-primary-on-color' : 'bg-label-primary-on-color'
)} )}
style={{ width: `${percentage}%` }} style={{ width: `${percentage}%` }}
/> />
)}
</div> </div>
</div> </div>
</div> </div>
@@ -332,26 +355,45 @@ export function PollMessageContents({
})} })}
</div> </div>
{totalVotes > 0 ? ( <div className={tw('mt-4 flex h-10 items-center justify-center')}>
<div className={tw('mt-4 flex justify-center scheme-light')}> <AnimatePresence exitBeforeEnter initial={false}>
<AxoButton.Root {totalVotes > 0 ? (
size="md" <motion.div
variant="floating-secondary" key="view-votes"
onClick={() => setShowVotesModal(true)} initial={{ opacity: 0 }}
> animate={{
{i18n('icu:PollMessage__ViewVotesButton')} opacity: 1,
</AxoButton.Root> transition: { duration: 0.25, ease: 'easeOut' },
</div> }}
) : ( exit={{
<div opacity: 0,
className={tw( transition: { duration: 0.25, ease: 'easeIn' },
'mt-4 text-center type-body-medium', }}
isIncoming ? 'text-label-primary' : 'text-label-primary-on-color' className={tw('scheme-light')}
>
<AxoButton.Root
size="md"
variant="floating-secondary"
onClick={() => setShowVotesModal(true)}
>
{i18n('icu:PollMessage__ViewVotesButton')}
</AxoButton.Root>
</motion.div>
) : (
<motion.div
key="no-votes"
className={tw(
'type-body-medium',
isIncoming
? 'text-label-primary'
: 'text-label-primary-on-color'
)}
>
{i18n('icu:PollVotesModal__noVotes')}
</motion.div>
)} )}
> </AnimatePresence>
{i18n('icu:PollVotesModal__noVotes')} </div>
</div>
)}
{showVotesModal && ( {showVotesModal && (
<PollVotesModal <PollVotesModal