diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2fd6ab55f7..a24a95f059 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1374,6 +1374,42 @@ "messageformat": "Info", "description": "Shown on the drop-down menu for an individual message, takes you to message detail screen" }, + "icu:PollMessage--SelectOne": { + "messageformat": "Poll ยท Select one", + "description": "Status text for single-choice poll where user can select one option" + }, + "icu:PollMessage--SelectMultiple": { + "messageformat": "Poll ยท Select one or more", + "description": "Status text for multiple-choice poll where user can select multiple options" + }, + "icu:PollMessage--FinalResults": { + "messageformat": "Poll ยท Final results", + "description": "Status text for terminated poll showing final results" + }, + "icu:PollMessage__ViewVotesButton": { + "messageformat": "View votes", + "description": "Button text to view poll votes details" + }, + "icu:PollMessage--YouVoted": { + "messageformat": "You voted", + "description": "Accessibility label for checkmark indicating user voted for this poll option" + }, + "icu:PollVotesModal__title": { + "messageformat": "Poll details", + "description": "Modal title for viewing poll votes and who voted on which option" + }, + "icu:PollVotesModal__questionLabel": { + "messageformat": "Question", + "description": "Label for the poll question in the votes modal" + }, + "icu:PollVotesModal__voteCount": { + "messageformat": "{count, plural, one {{count,number} vote} other {{count,number} votes}}", + "description": "Vote count display header for a specific poll option" + }, + "icu:PollVotesModal__noVotes": { + "messageformat": "No votes", + "description": "Message shown when poll has no votes" + }, "icu:deleteConversation": { "messageformat": "Delete", "description": "Menu item for deleting a conversation (including messages), title case." diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index 421ae96641..6eb557dcb5 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -159,7 +159,6 @@ audio { } button { - font-size: inherit; -webkit-app-region: no-drag; } button:not(:disabled) { diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index a60156b52d..808aef4378 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -96,10 +96,9 @@ import { isPaymentNotificationEvent } from '../../types/Payment.js'; import type { AnyPaymentEvent } from '../../types/Payment.js'; import { getPaymentEventDescription } from '../../messages/helpers.js'; import { PanelType } from '../../types/Panels.js'; -import { - type PollMessageAttribute, - isPollReceiveEnabled, -} from '../../types/Polls.js'; +import { isPollReceiveEnabled } from '../../types/Polls.js'; +import type { PollWithResolvedVotersType } from '../../state/selectors/message.js'; +import { PollMessageContents } from './poll-message/PollMessageContents.js'; import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser.js'; import { RenderLocation } from './MessageTextRenderer.js'; import { UserText } from '../UserText.js'; @@ -307,7 +306,7 @@ export type PropsData = { attachments?: ReadonlyArray; giftBadge?: GiftBadgeType; payment?: AnyPaymentEvent; - poll?: PollMessageAttribute; + poll?: PollWithResolvedVotersType; quote?: { conversationColor: ConversationColorType; conversationTitle: string; @@ -2082,19 +2081,12 @@ export class Message extends React.PureComponent { } public renderPoll(): JSX.Element | null { - const { poll, direction } = this.props; + const { poll, direction, i18n } = this.props; if (!poll || !isPollReceiveEnabled()) { return null; } return ( -
-
{JSON.stringify(poll, null, 2)}
-
+ ); } diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index 4b84c65dd2..311454e70e 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -373,6 +373,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ theme: ThemeType.light, timestamp: overrideProps.timestamp ?? Date.now(), viewStory: action('viewStory'), + poll: overrideProps.poll, }); const renderMany = (propsArray: ReadonlyArray) => ( @@ -1997,6 +1998,196 @@ AudioWithPendingAttachment.args = { status: 'sent', }; +// Poll Messages + +function createMockPollWithVotes( + question: string, + options: Array, + allowMultiple: boolean, + votes?: Array<{ + fromId: string; + optionIndexes: Array; + }>, + terminatedAt?: number +) { + const resolvedVotes = + votes?.map((vote, idx) => { + const name = vote.fromId === 'me' ? 'You' : vote.fromId; + + return { + optionIndexes: vote.optionIndexes, + timestamp: Date.now() - (idx + 1) * 1000, + isMe: vote.fromId === 'me', + from: { + acceptedMessageRequest: true, + avatarUrl: undefined, + badges: [], + color: ConversationColors[idx % ConversationColors.length], + id: vote.fromId, + isMe: vote.fromId === 'me', + name, + phoneNumber: undefined, + profileName: undefined, + sharedGroupNames: [], + title: name, + }, + }; + }) || []; + + const votesByOption = new Map(); + let totalNumVotes = 0; + + resolvedVotes.forEach(vote => { + vote.optionIndexes.forEach(index => { + if (!votesByOption.has(index)) { + votesByOption.set(index, []); + } + votesByOption.get(index).push(vote); + totalNumVotes += 1; + }); + }); + + return { + question, + options, + allowMultiple, + votesByOption, + totalNumVotes, + terminatedAt, + votes: votes?.map(v => ({ + fromConversationId: v.fromId, + optionIndexes: v.optionIndexes, + voteCount: 1, + timestamp: Date.now(), + })), + }; +} + +export const Poll = Template.bind({}); +Poll.args = { + conversationType: 'group', + poll: { + question: 'What should we have for lunch?', + options: ['Pizza ๐Ÿ•', 'Sushi ๐Ÿฑ', 'Tacos ๐ŸŒฎ', 'Salad ๐Ÿฅ—'], + allowMultiple: false, + votesByOption: new Map(), + totalNumVotes: 0, + }, + status: 'sent', +}; + +export const PollMultipleChoice = Template.bind({}); +PollMultipleChoice.args = { + conversationType: 'group', + poll: { + question: 'Which features would you like to see in the next update?', + options: ['Dark mode', 'Video calls', 'File sharing', 'Reactions', 'Polls'], + allowMultiple: true, + votesByOption: new Map(), + totalNumVotes: 0, + }, + status: 'sent', +}; + +export const PollWithVotes = Template.bind({}); +PollWithVotes.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] }, + ] + ), + status: 'read', +}; + +export const PollTerminated = Template.bind({}); +PollTerminated.args = { + conversationType: 'group', + poll: createMockPollWithVotes( + 'Quick poll: Coffee or tea?', + ['Coffee โ˜•', 'Tea ๐Ÿต'], + false, + [ + { fromId: 'alice', optionIndexes: [0] }, + { fromId: 'user1', optionIndexes: [0] }, + { fromId: 'user2', optionIndexes: [0] }, + { fromId: 'user3', optionIndexes: [0] }, + { fromId: 'user4', optionIndexes: [0] }, + { fromId: 'user5', optionIndexes: [0] }, + { fromId: 'me', optionIndexes: [0] }, + { fromId: 'bob', optionIndexes: [1] }, + { fromId: 'user6', optionIndexes: [1] }, + { fromId: 'user7', optionIndexes: [1] }, + { fromId: 'user8', optionIndexes: [1] }, + ], + Date.now() - 60000 + ), + status: 'read', +}; + +export const PollLongText = Template.bind({}); +PollLongText.args = { + conversationType: 'group', + poll: createMockPollWithVotes( + '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 + 'Hybrid model with 2-3 days in office for collaboration and team meetings', // 72 chars + 'Mostly office-based with occasional work from home days when really needed for personal appointments', // 100 chars (max!) + 'Traditional full-time office presence with standard 9-5 schedule', // 64 chars + 'Flexible arrangement based on project needs and deadlines', // 57 chars + ], + false, + [ + { fromId: 'alice', optionIndexes: [0] }, + { fromId: 'bob', optionIndexes: [1] }, + { fromId: 'charlie', optionIndexes: [1] }, + { fromId: 'me', optionIndexes: [2] }, + { fromId: 'dana', optionIndexes: [2] }, + { fromId: 'eve', optionIndexes: [3] }, + ] + ), + status: 'sent', +}; + +export const PollMultipleChoiceWithVotes = Template.bind({}); +PollMultipleChoiceWithVotes.args = { + conversationType: 'group', + poll: createMockPollWithVotes( + 'Which toppings do you want on the pizza?', + [ + 'Pepperoni', + 'Mushrooms', + 'Sausage', + 'Bell Peppers', + 'Olives', + 'Extra Cheese', + ], + true, + [ + { fromId: 'alice', optionIndexes: [0, 2, 5] }, // Pepperoni, Sausage, Extra Cheese + { fromId: 'bob', optionIndexes: [1, 3, 4] }, // Mushrooms, Bell Peppers, Olives + { fromId: 'charlie', optionIndexes: [0, 1] }, // Pepperoni, Mushrooms + { fromId: 'me', optionIndexes: [0, 3, 5] }, // Pepperoni, Bell Peppers, Extra Cheese + { fromId: 'dana', optionIndexes: [2, 4, 5] }, // Sausage, Olives, Extra Cheese + ] + ), + status: 'read', +}; + export const OtherFileType = Template.bind({}); OtherFileType.args = { attachments: [ diff --git a/ts/components/conversation/poll-message/PollMessageContents.tsx b/ts/components/conversation/poll-message/PollMessageContents.tsx new file mode 100644 index 0000000000..a01e51653c --- /dev/null +++ b/ts/components/conversation/poll-message/PollMessageContents.tsx @@ -0,0 +1,240 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { memo, useState } from 'react'; +import { Checkbox } from 'radix-ui'; +import { tw } from '../../../axo/tw.js'; +import { AxoButton } from '../../../axo/AxoButton.js'; +import { AxoSymbol } from '../../../axo/AxoSymbol.js'; +import type { DirectionType } from '../Message.js'; +import type { PollWithResolvedVotersType } from '../../../state/selectors/message.js'; +import type { LocalizerType } from '../../../types/Util.js'; +import { PollVotesModal } from './PollVotesModal.js'; + +function VotedCheckmark({ + isIncoming, + i18n, +}: { + isIncoming: boolean; + i18n: LocalizerType; +}): JSX.Element { + return ( +
+ +
+ ); +} + +type PollCheckboxProps = { + checked: boolean; + onCheckedChange: (nextChecked: boolean) => void; + isIncoming: boolean; +}; + +const PollCheckbox = memo((props: PollCheckboxProps) => { + const { isIncoming } = props; + + return ( + + + + + + ); +}); + +PollCheckbox.displayName = 'PollCheckbox'; + +export type PollMessageContentsProps = { + poll: PollWithResolvedVotersType; + direction: DirectionType; + i18n: LocalizerType; +}; + +export function PollMessageContents({ + poll, + direction, + i18n, +}: PollMessageContentsProps): JSX.Element { + const [showVotesModal, setShowVotesModal] = useState(false); + const isIncoming = direction === 'incoming'; + + const totalVotes = poll.totalNumVotes; + + let pollStatusText: string; + if (poll.terminatedAt) { + pollStatusText = i18n('icu:PollMessage--FinalResults'); + } else if (poll.allowMultiple) { + pollStatusText = i18n('icu:PollMessage--SelectMultiple'); + } else { + pollStatusText = i18n('icu:PollMessage--SelectOne'); + } + + return ( +
+
{poll.question}
+ +
+ {pollStatusText} +
+ + {/* Poll Options */} +
+ {poll.options.map((option, index) => { + const pollVoteEntries = poll.votesByOption.get(index); + const optionVotes = pollVoteEntries?.length ?? 0; + const percentage = + totalVotes > 0 ? (optionVotes / totalVotes) * 100 : 0; + + const weVotedForThis = (pollVoteEntries ?? []).some( + vote => vote.isMe && vote.optionIndexes.includes(index) + ); + + return ( + // eslint-disable-next-line react/no-array-index-key +
+ {poll.terminatedAt == null && ( + // 3px offset: type-body-large has 14px font-size and 20px line-height, + // creating 3px space above text. This aligns checkbox with text baseline. +
+ {}} + isIncoming={isIncoming} + /> +
+ )} + +
+
+ {option} + {totalVotes > 0 && ( +
+ {poll.terminatedAt != null && weVotedForThis && ( + + )} + + {optionVotes} + +
+ )} +
+ +
+
+ {percentage > 0 && ( +
+ )} +
+
+
+ ); + })} +
+ + {totalVotes > 0 ? ( +
+ setShowVotesModal(true)} + > + {i18n('icu:PollMessage__ViewVotesButton')} + +
+ ) : ( +
+ {i18n('icu:PollVotesModal__noVotes')} +
+ )} + + {showVotesModal && ( + setShowVotesModal(false)} + /> + )} +
+ ); +} diff --git a/ts/components/conversation/poll-message/PollVotesModal.tsx b/ts/components/conversation/poll-message/PollVotesModal.tsx new file mode 100644 index 0000000000..15c2affd0b --- /dev/null +++ b/ts/components/conversation/poll-message/PollVotesModal.tsx @@ -0,0 +1,110 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { tw } from '../../../axo/tw.js'; +import { Modal } from '../../Modal.js'; +import { Avatar, AvatarSize } from '../../Avatar.js'; +import { ContactName } from '../ContactName.js'; +import type { LocalizerType } from '../../../types/Util.js'; +import type { + PollVoteWithUserType, + PollWithResolvedVotersType, +} from '../../../state/selectors/message.js'; + +type PollVotesModalProps = { + i18n: LocalizerType; + poll: PollWithResolvedVotersType; + onClose: () => void; +}; + +export function PollVotesModal({ + i18n, + poll, + onClose, +}: PollVotesModalProps): JSX.Element { + return ( + +
+
+
+ {i18n('icu:PollVotesModal__questionLabel')} +
+ +
{poll.question}
+
+ + {poll.options.map((option, index) => { + const voters = poll.votesByOption.get(index) || []; + const optionKey = `option-${index}`; + + return ( +
+ {/* Option Header */} +
+
{option}
+
+ {i18n('icu:PollVotesModal__voteCount', { + count: voters.length, + })} +
+
+ + {/* Voters List */} +
+ {voters.map((vote: PollVoteWithUserType) => ( +
+ +
+ +
+
+ ))} +
+
+ ); + })} + + {poll.totalNumVotes === 0 && ( +
+ {i18n('icu:PollVotesModal__noVotes')} +
+ )} +
+
+ ); +} diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index edc337ab62..4ecdc6a9fa 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -145,6 +145,7 @@ import { calculateExpirationTimestamp } from '../../util/expirationTimer.js'; import { isSignalConversation } from '../../util/isSignalConversation.js'; import type { AnyPaymentEvent } from '../../types/Payment.js'; import { isPaymentNotificationEvent } from '../../types/Payment.js'; +import type { PollMessageAttribute } from '../../types/Polls.js'; import { getTitleNoDefault, getTitle, @@ -474,6 +475,103 @@ const getReactionsForMessage = ( return [...formattedReactions]; }; +export type PollVoteWithUserType = { + optionIndexes: ReadonlyArray; + timestamp: number; + isMe: boolean; + from: Pick< + ConversationType, + | 'acceptedMessageRequest' + | 'avatarUrl' + | 'badges' + | 'color' + | 'id' + | 'isMe' + | 'name' + | 'phoneNumber' + | 'profileName' + | 'sharedGroupNames' + | 'title' + >; +}; + +export type PollWithResolvedVotersType = PollMessageAttribute & { + votesByOption: Map>; + totalNumVotes: number; +}; + +const getPollForMessage = ( + message: MessageWithUIFieldsType, + { + conversationSelector, + ourConversationId, + }: { + conversationSelector: GetConversationByIdType; + ourConversationId?: string; + } +): PollWithResolvedVotersType | undefined => { + const { poll } = message; + if (!poll) { + return undefined; + } + + if (!poll.votes || poll.votes.length === 0) { + return { + ...poll, + votesByOption: new Map(), + totalNumVotes: 0, + }; + } + + const resolvedVotes: ReadonlyArray = poll.votes.map( + vote => { + const voter = conversationSelector(vote.fromConversationId); + + const from: PollVoteWithUserType['from'] = { + acceptedMessageRequest: voter.acceptedMessageRequest, + avatarUrl: voter.avatarUrl, + badges: voter.badges, + color: voter.color, + id: voter.id, + isMe: voter.isMe, + name: voter.name, + phoneNumber: voter.phoneNumber, + profileName: voter.profileName, + sharedGroupNames: voter.sharedGroupNames, + title: voter.title, + }; + + return { + optionIndexes: vote.optionIndexes, + timestamp: vote.timestamp, + isMe: voter.id === ourConversationId, + from, + }; + } + ); + + const votesByOption = new Map>(); + let totalNumVotes = 0; + + for (const vote of resolvedVotes) { + for (const optionIndex of vote.optionIndexes) { + if (!votesByOption.has(optionIndex)) { + votesByOption.set(optionIndex, []); + } + const votes = votesByOption.get(optionIndex); + strictAssert(!!votes, 'votes should exist'); + votes.push(vote); + totalNumVotes += 1; + } + } + + return { + ...poll, + votesByOption, + totalNumVotes, + }; +}; + const getPropsForStoryReplyContext = ( message: Pick< MessageWithUIFieldsType, @@ -782,7 +880,10 @@ export const getPropsForMessage = ( expirationStartTimestamp, }), giftBadge: message.giftBadge, - poll: message.poll, + poll: getPollForMessage(message, { + conversationSelector: options.conversationSelector, + ourConversationId, + }), id: message.id, isBlocked: conversation.isBlocked || false, isEditedMessage: Boolean(message.editHistory),