mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
@@ -10,7 +10,7 @@ export type Props = {
|
|||||||
value?: number | 'indeterminate'; // default: 'indeterminate'
|
value?: number | 'indeterminate'; // default: 'indeterminate'
|
||||||
min?: number; // default: 0
|
min?: number; // default: 0
|
||||||
max?: number; // default: 1
|
max?: number; // default: 1
|
||||||
variant?: SpinnerVariant;
|
variant?: SpinnerVariant | SpinnerVariantStyles;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
marginRatio?: number;
|
marginRatio?: number;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -85,7 +85,8 @@ export function SpinnerV2({
|
|||||||
);
|
);
|
||||||
const circumference = radius * 2 * Math.PI;
|
const circumference = radius * 2 * Math.PI;
|
||||||
|
|
||||||
const { bg, fg } = SpinnerVariants[variant];
|
const { bg, fg } =
|
||||||
|
typeof variant === 'string' ? SpinnerVariants[variant] : variant;
|
||||||
|
|
||||||
const bgElem = (
|
const bgElem = (
|
||||||
<circle
|
<circle
|
||||||
|
|||||||
@@ -1992,6 +1992,7 @@ function createMockPollWithVotes(
|
|||||||
fromId: string;
|
fromId: string;
|
||||||
optionIndexes: Array<number>;
|
optionIndexes: Array<number>;
|
||||||
}>,
|
}>,
|
||||||
|
pendingVoteDiff?: Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>,
|
||||||
terminatedAt?: number
|
terminatedAt?: number
|
||||||
) {
|
) {
|
||||||
const resolvedVotes =
|
const resolvedVotes =
|
||||||
@@ -2041,6 +2042,7 @@ function createMockPollWithVotes(
|
|||||||
totalNumVotes,
|
totalNumVotes,
|
||||||
uniqueVoters: uniqueVoterIds.size,
|
uniqueVoters: uniqueVoterIds.size,
|
||||||
terminatedAt,
|
terminatedAt,
|
||||||
|
pendingVoteDiff,
|
||||||
votes: votes?.map(v => ({
|
votes: votes?.map(v => ({
|
||||||
fromConversationId: v.fromId,
|
fromConversationId: v.fromId,
|
||||||
optionIndexes: v.optionIndexes,
|
optionIndexes: v.optionIndexes,
|
||||||
@@ -2102,6 +2104,34 @@ PollWithVotes.args = {
|
|||||||
status: 'read',
|
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({});
|
export const PollTerminated = Template.bind({});
|
||||||
PollTerminated.args = {
|
PollTerminated.args = {
|
||||||
conversationType: 'group',
|
conversationType: 'group',
|
||||||
@@ -2122,6 +2152,7 @@ PollTerminated.args = {
|
|||||||
{ fromId: 'user7', optionIndexes: [1] },
|
{ fromId: 'user7', optionIndexes: [1] },
|
||||||
{ fromId: 'user8', optionIndexes: [1] },
|
{ fromId: 'user8', optionIndexes: [1] },
|
||||||
],
|
],
|
||||||
|
undefined,
|
||||||
Date.now() - 60000
|
Date.now() - 60000
|
||||||
),
|
),
|
||||||
status: 'read',
|
status: 'read',
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 { 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 { AxoButton } from '../../../axo/AxoButton.dom.js';
|
||||||
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
|
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
|
||||||
import type { DirectionType } from '../Message.dom.js';
|
import type { DirectionType } from '../Message.dom.js';
|
||||||
import type { PollWithResolvedVotersType } from '../../../state/selectors/message.preload.js';
|
import type { PollWithResolvedVotersType } from '../../../state/selectors/message.preload.js';
|
||||||
import type { LocalizerType } from '../../../types/Util.std.js';
|
import type { LocalizerType } from '../../../types/Util.std.js';
|
||||||
import { PollVotesModal } from './PollVotesModal.dom.js';
|
import { PollVotesModal } from './PollVotesModal.dom.js';
|
||||||
|
import { SpinnerV2 } from '../../SpinnerV2.dom.js';
|
||||||
|
import { usePrevious } from '../../../hooks/usePrevious.std.js';
|
||||||
|
|
||||||
function VotedCheckmark({
|
function VotedCheckmark({
|
||||||
isIncoming,
|
isIncoming,
|
||||||
@@ -41,39 +43,78 @@ type PollCheckboxProps = {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
onCheckedChange: (nextChecked: boolean) => void;
|
onCheckedChange: (nextChecked: boolean) => void;
|
||||||
isIncoming: boolean;
|
isIncoming: boolean;
|
||||||
|
isPending: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PollCheckbox = memo((props: PollCheckboxProps) => {
|
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 (
|
return (
|
||||||
<Checkbox.Root
|
<>
|
||||||
checked={props.checked}
|
{isPending ? (
|
||||||
onCheckedChange={props.onCheckedChange}
|
<div className={tw('pointer-events-none absolute')}>
|
||||||
className={tw(
|
<SpinnerV2
|
||||||
'flex size-6 items-center justify-center rounded-full',
|
value="indeterminate"
|
||||||
'border-[1.5px]',
|
size={24}
|
||||||
'outline-0 outline-border-focused focused:outline-[2.5px]',
|
strokeWidth={1.5}
|
||||||
'overflow-hidden',
|
marginRatio={1}
|
||||||
// Unchecked states
|
variant={{
|
||||||
'data-[state=unchecked]:bg-transparent',
|
bg: tw('stroke-none'),
|
||||||
isIncoming
|
fg: strokeColor,
|
||||||
? 'data-[state=unchecked]:border-label-placeholder'
|
}}
|
||||||
: 'data-[state=unchecked]:border-label-primary-on-color',
|
/>
|
||||||
// Checked states
|
</div>
|
||||||
isIncoming
|
) : null}
|
||||||
? 'data-[state=checked]:border-color-fill-primary data-[state=checked]:bg-color-fill-primary'
|
<Checkbox.Root
|
||||||
: 'data-[state=checked]:border-label-primary-on-color data-[state=checked]:bg-label-primary-on-color'
|
checked={props.checked}
|
||||||
)}
|
onCheckedChange={props.onCheckedChange}
|
||||||
>
|
|
||||||
<Checkbox.Indicator
|
|
||||||
className={tw(
|
className={tw(
|
||||||
isIncoming ? 'text-label-primary-on-color' : 'text-color-fill-primary'
|
'flex size-6 items-center justify-center rounded-full',
|
||||||
|
isPending ? '' : 'border-[1.5px]',
|
||||||
|
'outline-0 outline-border-focused focused:outline-[2.5px]',
|
||||||
|
'overflow-hidden',
|
||||||
|
bgColor,
|
||||||
|
borderColor
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AxoSymbol.Icon symbol="check" size={16} label={null} />
|
<Checkbox.Indicator
|
||||||
</Checkbox.Indicator>
|
className={tw(checkmarkColor, 'flex items-center justify-center')}
|
||||||
</Checkbox.Root>
|
>
|
||||||
|
<AxoSymbol.Icon symbol="check" size={16} label={null} />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox.Root>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +133,7 @@ export type PollMessageContentsProps = {
|
|||||||
canEndPoll?: boolean;
|
canEndPoll?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DELAY_BEFORE_SHOWING_PENDING_ANIMATION = 500;
|
||||||
export function PollMessageContents({
|
export function PollMessageContents({
|
||||||
poll,
|
poll,
|
||||||
direction,
|
direction,
|
||||||
@@ -102,9 +144,34 @@ export function PollMessageContents({
|
|||||||
canEndPoll,
|
canEndPoll,
|
||||||
}: PollMessageContentsProps): JSX.Element {
|
}: PollMessageContentsProps): JSX.Element {
|
||||||
const [showVotesModal, setShowVotesModal] = useState(false);
|
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 isIncoming = direction === 'incoming';
|
||||||
|
|
||||||
const { totalNumVotes: totalVotes, uniqueVoters } = poll;
|
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;
|
let pollStatusText: string;
|
||||||
if (poll.terminatedAt) {
|
if (poll.terminatedAt) {
|
||||||
@@ -115,10 +182,7 @@ export function PollMessageContents({
|
|||||||
pollStatusText = i18n('icu:PollMessage--SelectOne');
|
pollStatusText = i18n('icu:PollMessage--SelectOne');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePollOptionClicked(
|
function handlePollOptionClicked(index: number, nextChecked: boolean): void {
|
||||||
index: number,
|
|
||||||
nextChecked: boolean
|
|
||||||
): Promise<void> {
|
|
||||||
const existingSelections = Array.from(
|
const existingSelections = Array.from(
|
||||||
poll.votesByOption
|
poll.votesByOption
|
||||||
.entries()
|
.entries()
|
||||||
@@ -127,6 +191,16 @@ export function PollMessageContents({
|
|||||||
);
|
);
|
||||||
const optionIndexes = new Set<number>(existingSelections);
|
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 (nextChecked) {
|
||||||
if (!poll.allowMultiple) {
|
if (!poll.allowMultiple) {
|
||||||
// Single-select: clear existing selections first
|
// Single-select: clear existing selections first
|
||||||
@@ -174,6 +248,12 @@ export function PollMessageContents({
|
|||||||
uniqueVoters > 0 ? (optionVotes / uniqueVoters) * 100 : 0;
|
uniqueVoters > 0 ? (optionVotes / uniqueVoters) * 100 : 0;
|
||||||
|
|
||||||
const weVotedForThis = (pollVoteEntries ?? []).some(v => v.isMe);
|
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 (
|
return (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
// 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.
|
// creating 3px space above text. This aligns checkbox with text baseline.
|
||||||
<div className={tw('mt-[3px] self-start')}>
|
<div className={tw('mt-[3px] self-start')}>
|
||||||
<PollCheckbox
|
<PollCheckbox
|
||||||
checked={weVotedForThis}
|
checked={shouldShowCheckmark}
|
||||||
onCheckedChange={next =>
|
onCheckedChange={next =>
|
||||||
handlePollOptionClicked(index, Boolean(next))
|
handlePollOptionClicked(index, Boolean(next))
|
||||||
}
|
}
|
||||||
isIncoming={isIncoming}
|
isIncoming={isIncoming}
|
||||||
|
isPending={isVotePending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ export type PollWithResolvedVotersType = PollMessageAttribute & {
|
|||||||
votesByOption: Map<number, ReadonlyArray<PollVoteWithUserType>>;
|
votesByOption: Map<number, ReadonlyArray<PollVoteWithUserType>>;
|
||||||
totalNumVotes: number;
|
totalNumVotes: number;
|
||||||
uniqueVoters: number;
|
uniqueVoters: number;
|
||||||
|
pendingVoteDiff?: Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPollForMessage = (
|
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
|
// Deduplicate votes by sender - keep only the newest vote per sender
|
||||||
// (highest voteCount, or newest timestamp if voteCount is equal)
|
// (highest voteCount, or newest timestamp if voteCount is equal)
|
||||||
const voteByFrom = new Map<string, MessagePollVoteType>();
|
const voteByFrom = new Map<string, MessagePollVoteType>();
|
||||||
for (const vote of poll.votes) {
|
for (const vote of votesToProcess) {
|
||||||
const existingVote = voteByFrom.get(vote.fromConversationId);
|
const existingVote = voteByFrom.get(vote.fromConversationId);
|
||||||
if (
|
if (
|
||||||
!existingVote ||
|
!existingVote ||
|
||||||
@@ -596,6 +640,7 @@ const getPollForMessage = (
|
|||||||
votesByOption,
|
votesByOption,
|
||||||
totalNumVotes,
|
totalNumVotes,
|
||||||
uniqueVoters: uniqueVoterIds.size,
|
uniqueVoters: uniqueVoterIds.size,
|
||||||
|
pendingVoteDiff,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2358,5 +2358,13 @@
|
|||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2025-11-02T17:27:24.705Z",
|
"updated": "2025-11-02T17:27:24.705Z",
|
||||||
"reasonDetail": "Map of refs for poll option inputs to manage focus"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user