mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Polls UI Enhancements
This commit is contained in:
@@ -1518,6 +1518,10 @@
|
|||||||
"messageformat": "No votes",
|
"messageformat": "No votes",
|
||||||
"description": "Message shown when poll has no votes"
|
"description": "Message shown when poll has no votes"
|
||||||
},
|
},
|
||||||
|
"icu:PollVotesModal__winningOption": {
|
||||||
|
"messageformat": "Winning",
|
||||||
|
"description": "Accessibility label for the star icon indicating the winning option in a poll"
|
||||||
|
},
|
||||||
"icu:Toast--PinnedMessageNotFound": {
|
"icu:Toast--PinnedMessageNotFound": {
|
||||||
"messageformat": "Pinned message not found",
|
"messageformat": "Pinned message not found",
|
||||||
"description": "Toast shown when user tries to view a pinned message that no longer exists"
|
"description": "Toast shown when user tries to view a pinned message that no longer exists"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { LocalizerType } from '../../types/Util.std.js';
|
|||||||
import type { ConversationType } from '../../state/ducks/conversations.preload.js';
|
import type { ConversationType } from '../../state/ducks/conversations.preload.js';
|
||||||
import { SystemMessage } from './SystemMessage.dom.js';
|
import { SystemMessage } from './SystemMessage.dom.js';
|
||||||
import { Button, ButtonVariant, ButtonSize } from '../Button.dom.js';
|
import { Button, ButtonVariant, ButtonSize } from '../Button.dom.js';
|
||||||
|
import { UserText } from '../UserText.dom.js';
|
||||||
|
import { I18n } from '../I18n.dom.js';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
sender: ConversationType;
|
sender: ConversationType;
|
||||||
@@ -24,17 +26,29 @@ export function PollTerminateNotification({
|
|||||||
i18n,
|
i18n,
|
||||||
scrollToPollMessage,
|
scrollToPollMessage,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const message = sender.isMe
|
|
||||||
? i18n('icu:PollTerminate--you', { poll: pollQuestion })
|
|
||||||
: i18n('icu:PollTerminate--other', {
|
|
||||||
name: sender.title,
|
|
||||||
poll: pollQuestion,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleViewPoll = () => {
|
const handleViewPoll = () => {
|
||||||
scrollToPollMessage(pollMessageId, conversationId);
|
scrollToPollMessage(pollMessageId, conversationId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const message = sender.isMe ? (
|
||||||
|
<I18n
|
||||||
|
i18n={i18n}
|
||||||
|
id="icu:PollTerminate--you"
|
||||||
|
components={{
|
||||||
|
poll: <UserText text={pollQuestion} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<I18n
|
||||||
|
i18n={i18n}
|
||||||
|
id="icu:PollTerminate--other"
|
||||||
|
components={{
|
||||||
|
name: <UserText text={sender.title} />,
|
||||||
|
poll: <UserText text={pollQuestion} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SystemMessage
|
<SystemMessage
|
||||||
symbol="poll"
|
symbol="poll"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 { SpinnerV2 } from '../../SpinnerV2.dom.js';
|
||||||
import { usePrevious } from '../../../hooks/usePrevious.std.js';
|
import { usePrevious } from '../../../hooks/usePrevious.std.js';
|
||||||
|
import { UserText } from '../../UserText.dom.js';
|
||||||
|
|
||||||
function VotedCheckmark({
|
function VotedCheckmark({
|
||||||
isIncoming,
|
isIncoming,
|
||||||
@@ -224,11 +225,13 @@ export function PollMessageContents({
|
|||||||
'text-start break-words whitespace-pre-wrap',
|
'text-start break-words whitespace-pre-wrap',
|
||||||
'type-body-large',
|
'type-body-large',
|
||||||
isIncoming ? 'text-label-primary' : 'text-label-primary-on-color',
|
isIncoming ? 'text-label-primary' : 'text-label-primary-on-color',
|
||||||
'min-w-[275px]',
|
'w-[275px] max-w-full',
|
||||||
'mt-1'
|
'mt-1'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={tw('mb-1 font-semibold')}>{poll.question}</div>
|
<div className={tw('mb-1 font-semibold')}>
|
||||||
|
<UserText text={poll.question} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={tw(
|
className={tw(
|
||||||
@@ -273,9 +276,11 @@ export function PollMessageContents({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={tw('flex flex-1 flex-col gap-1')}>
|
<div className={tw('flex min-w-0 flex-1 flex-col gap-1')}>
|
||||||
<div className={tw('flex items-start justify-between gap-3')}>
|
<div className={tw('flex items-start justify-between gap-3')}>
|
||||||
<span className={tw('type-body-large')}>{option}</span>
|
<span className={tw('min-w-0 type-body-large break-words')}>
|
||||||
|
<UserText text={option} />
|
||||||
|
</span>
|
||||||
{totalVotes > 0 && (
|
{totalVotes > 0 && (
|
||||||
<div className={tw('flex shrink-0 items-center gap-1')}>
|
<div className={tw('flex shrink-0 items-center gap-1')}>
|
||||||
{poll.terminatedAt != null && weVotedForThis && (
|
{poll.terminatedAt != null && weVotedForThis && (
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// 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 from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { tw } from '../../../axo/tw.dom.js';
|
import { 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 { Modal } from '../../Modal.dom.js';
|
import { Modal } from '../../Modal.dom.js';
|
||||||
import { Avatar, AvatarSize } from '../../Avatar.dom.js';
|
import { Avatar, AvatarSize } from '../../Avatar.dom.js';
|
||||||
import { ContactName } from '../ContactName.dom.js';
|
import { ContactName } from '../ContactName.dom.js';
|
||||||
|
import { UserText } from '../../UserText.dom.js';
|
||||||
import type { LocalizerType } from '../../../types/Util.std.js';
|
import type { LocalizerType } from '../../../types/Util.std.js';
|
||||||
import type {
|
import type {
|
||||||
PollVoteWithUserType,
|
PollVoteWithUserType,
|
||||||
@@ -30,6 +32,12 @@ export function PollVotesModal({
|
|||||||
canEndPoll,
|
canEndPoll,
|
||||||
messageId,
|
messageId,
|
||||||
}: PollVotesModalProps): JSX.Element {
|
}: PollVotesModalProps): JSX.Element {
|
||||||
|
const maxVoteCount = useMemo(() => {
|
||||||
|
return poll.votesByOption.values().reduce((max, voters) => {
|
||||||
|
return Math.max(max, voters.length);
|
||||||
|
}, 0);
|
||||||
|
}, [poll.votesByOption]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
modalName="PollVotesModal"
|
modalName="PollVotesModal"
|
||||||
@@ -45,13 +53,20 @@ export function PollVotesModal({
|
|||||||
{i18n('icu:PollVotesModal__questionLabel')}
|
{i18n('icu:PollVotesModal__questionLabel')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={tw('type-body-large')}>{poll.question}</div>
|
<div
|
||||||
|
className={tw(
|
||||||
|
'rounded-md bg-fill-secondary px-3 py-1 type-body-large'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UserText text={poll.question} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{poll.options.map((option, index, array) => {
|
{poll.options.map((option, index, array) => {
|
||||||
const voters = poll.votesByOption.get(index) || [];
|
const voters = poll.votesByOption.get(index) || [];
|
||||||
const optionKey = `option-${index}`;
|
const optionKey = `option-${index}`;
|
||||||
const isLastOption = index === array.length - 1;
|
const isLastOption = index === array.length - 1;
|
||||||
|
const isWinning = voters.length > 0 && voters.length === maxVoteCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={optionKey}>
|
<React.Fragment key={optionKey}>
|
||||||
@@ -62,45 +77,62 @@ export function PollVotesModal({
|
|||||||
'mb-3 flex items-start gap-3 text-label-primary'
|
'mb-3 flex items-start gap-3 text-label-primary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={tw('type-title-small')}>{option}</div>
|
<div className={tw('type-title-small')}>
|
||||||
|
<UserText text={option} />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={tw('ms-auto mt-[2px] shrink-0 type-body-medium')}
|
className={tw(
|
||||||
|
'ms-auto mt-[2px] flex shrink-0 items-center gap-1 type-body-medium'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{i18n('icu:PollVotesModal__voteCount', {
|
{isWinning && (
|
||||||
count: voters.length,
|
<AxoSymbol.InlineGlyph
|
||||||
})}
|
symbol="star-fill"
|
||||||
|
label={i18n('icu:PollVotesModal__winningOption')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{voters.length > 0 &&
|
||||||
|
i18n('icu:PollVotesModal__voteCount', {
|
||||||
|
count: voters.length,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Voters List */}
|
{/* Voters List */}
|
||||||
<div className={tw('flex flex-col gap-4')}>
|
{voters.length === 0 ? (
|
||||||
{voters.map((vote: PollVoteWithUserType) => (
|
<div className={tw('type-body-medium text-label-secondary')}>
|
||||||
<div
|
{i18n('icu:PollVotesModal__noVotes')}
|
||||||
key={vote.from.id}
|
</div>
|
||||||
className={tw('flex items-center gap-3')}
|
) : (
|
||||||
>
|
<div className={tw('flex flex-col gap-4')}>
|
||||||
<Avatar
|
{voters.map((vote: PollVoteWithUserType) => (
|
||||||
avatarUrl={vote.from.avatarUrl}
|
<div
|
||||||
badge={undefined}
|
key={vote.from.id}
|
||||||
color={vote.from.color}
|
className={tw('flex items-center gap-3')}
|
||||||
conversationType="direct"
|
>
|
||||||
i18n={i18n}
|
<Avatar
|
||||||
noteToSelf={false}
|
avatarUrl={vote.from.avatarUrl}
|
||||||
phoneNumber={vote.from.phoneNumber}
|
badge={undefined}
|
||||||
profileName={vote.from.profileName}
|
color={vote.from.color}
|
||||||
sharedGroupNames={vote.from.sharedGroupNames}
|
conversationType="direct"
|
||||||
size={AvatarSize.THIRTY_SIX}
|
i18n={i18n}
|
||||||
title={vote.from.title}
|
noteToSelf={false}
|
||||||
/>
|
phoneNumber={vote.from.phoneNumber}
|
||||||
<div className={tw('min-w-0 flex-1')}>
|
profileName={vote.from.profileName}
|
||||||
<ContactName
|
sharedGroupNames={vote.from.sharedGroupNames}
|
||||||
|
size={AvatarSize.THIRTY_SIX}
|
||||||
title={vote.from.title}
|
title={vote.from.title}
|
||||||
module={tw('type-body-large text-label-primary')}
|
|
||||||
/>
|
/>
|
||||||
|
<div className={tw('min-w-0 flex-1')}>
|
||||||
|
<ContactName
|
||||||
|
title={vote.from.title}
|
||||||
|
module={tw('type-body-large text-label-primary')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isLastOption && (
|
{!isLastOption && (
|
||||||
<hr className={tw('border-t-[0.5px] border-label-secondary')} />
|
<hr className={tw('border-t-[0.5px] border-label-secondary')} />
|
||||||
|
|||||||
Reference in New Issue
Block a user