Add ability for poll author to terminate a poll

This commit is contained in:
yash-signal
2025-11-10 13:18:31 -06:00
committed by GitHub
parent 30548a6a3c
commit d328b45a28
39 changed files with 897 additions and 89 deletions

View File

@@ -1462,6 +1462,10 @@
"messageformat": "Info",
"description": "Shown on the drop-down menu for an individual message, takes you to message detail screen"
},
"icu:Poll__end-poll": {
"messageformat": "End poll",
"description": "Label for button/menu item to end a poll. Shown in the poll votes modal and in the message context menu"
},
"icu:PollMessage--SelectOne": {
"messageformat": "Poll · Select one",
"description": "Status text for single-choice poll where user can select one option"
@@ -1482,6 +1486,18 @@
"messageformat": "You voted",
"description": "Accessibility label for checkmark indicating user voted for this poll option"
},
"icu:PollTerminate--you": {
"messageformat": "You ended the poll: \"{poll}\"",
"description": "Chat event shown when you end a poll"
},
"icu:PollTerminate--other": {
"messageformat": "{name} ended the poll: \"{poll}\"",
"description": "Chat event shown when someone else ends a poll"
},
"icu:PollTerminate__view-poll": {
"messageformat": "View poll",
"description": "Button in poll terminate chat event to scroll to and select the original poll message"
},
"icu:PollVotesModal__title": {
"messageformat": "Poll details",
"description": "Modal title for viewing poll votes and who voted on which option"
@@ -1498,6 +1514,10 @@
"messageformat": "No votes",
"description": "Message shown when poll has no votes"
},
"icu:Toast--PollNotFound": {
"messageformat": "Poll not found",
"description": "Toast shown when user tries to view a poll that no longer exists"
},
"icu:PollCreateModal__title": {
"messageformat": "New poll",
"description": "Title for the modal to create a new poll"

View File

@@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#stop-circle__a)" fill="#000"><path d="M7.083 8.125c0-.575.467-1.042 1.042-1.042h3.75c.575 0 1.042.467 1.042 1.042v3.75c0 .575-.467 1.042-1.042 1.042h-3.75a1.042 1.042 0 0 1-1.042-1.042v-3.75Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M10 .938a9.063 9.063 0 1 0 0 18.125A9.063 9.063 0 0 0 10 .938ZM2.396 10a7.604 7.604 0 1 1 15.208 0 7.604 7.604 0 0 1-15.208 0Z"/></g><defs><clipPath id="stop-circle__a"><path fill="#fff" d="M0 0h20v20H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 567 B

View File

@@ -6785,6 +6785,13 @@ button.module-calling-participants-list__contact {
}
}
&__end-poll::before {
@include mixins.color-svg(
'../images/icons/v3/stop/stop-circle.svg',
light-dark(variables.$color-black, variables.$color-gray-15)
);
}
&__more-info::before {
@include mixins.light-theme {
@include mixins.color-svg(

View File

@@ -53,7 +53,7 @@
}
}
&::before {
&--has-icon::before {
content: '';
display: inline-block;
height: 16px;

View File

@@ -52,6 +52,7 @@ const MESSAGE_DEFAULT_PROPS = {
previews: [],
retryMessageSend: shouldNeverBeCalled,
sendPollVote: shouldNeverBeCalled,
endPoll: shouldNeverBeCalled,
pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => <div />,
renderingContext: 'EditHistoryMessagesModal',

View File

@@ -66,6 +66,7 @@ const MESSAGE_DEFAULT_PROPS = {
previews: [],
retryMessageSend: shouldNeverBeCalled,
sendPollVote: shouldNeverBeCalled,
endPoll: shouldNeverBeCalled,
pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => <div />,
saveAttachment: shouldNeverBeCalled,

View File

@@ -189,6 +189,8 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.OriginalMessageNotFound };
case ToastType.PinnedConversationsFull:
return { toastType: ToastType.PinnedConversationsFull };
case ToastType.PollNotFound:
return { toastType: ToastType.PollNotFound };
case ToastType.ReactionFailed:
return { toastType: ToastType.ReactionFailed };
case ToastType.ReceiptSaved:

View File

@@ -652,6 +652,10 @@ export function renderToast({
);
}
if (toastType === ToastType.PollNotFound) {
return <Toast onClose={hideToast}>{i18n('icu:Toast--PollNotFound')}</Toast>;
}
if (toastType === ToastType._InternalMainProcessLoggingError) {
return (
<Toast

View File

@@ -138,6 +138,7 @@ export const CallingNotification: React.FC<PropsType> = React.memo(
onEdit={undefined}
onReplyToMessage={undefined}
onReact={undefined}
onEndPoll={undefined}
onRetryMessageSend={undefined}
onRetryDeleteForEveryone={undefined}
onCopy={undefined}

View File

@@ -249,6 +249,7 @@ export type PropsData = {
isSelectMode: boolean;
isSMS: boolean;
isSpoilerExpanded?: Record<number, boolean>;
canEndPoll?: boolean;
direction: DirectionType;
timestamp: number;
receivedAtMS?: number;
@@ -363,6 +364,7 @@ export type PropsActions = {
messageId: string;
optionIndexes: ReadonlyArray<number>;
}) => void;
endPoll: (messageId: string) => void;
showContactModal: (contactId: string, conversationId?: string) => void;
showSpoiler: (messageId: string, data: Record<number, boolean>) => void;
@@ -2023,7 +2025,7 @@ export class Message extends React.PureComponent<Props, State> {
}
public renderPoll(): JSX.Element | null {
const { poll, direction, i18n, id } = this.props;
const { poll, direction, i18n, id, endPoll, canEndPoll } = this.props;
if (!poll || !isPollReceiveEnabled()) {
return null;
}
@@ -2034,6 +2036,8 @@ export class Message extends React.PureComponent<Props, State> {
i18n={i18n}
messageId={id}
sendPollVote={this.props.sendPollVote}
endPoll={endPoll}
canEndPoll={canEndPoll}
/>
);
}

View File

@@ -22,6 +22,7 @@ type MessageContextProps = {
onEdit: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined;
onReact: (() => void) | undefined;
onEndPoll: (() => void) | undefined;
onRetryMessageSend: (() => void) | undefined;
onRetryDeleteForEveryone: (() => void) | undefined;
onCopy: (() => void) | undefined;
@@ -39,6 +40,7 @@ export const MessageContextMenu = ({
onEdit,
onReplyToMessage,
onReact,
onEndPoll,
onMoreInfo,
onCopy,
onSelect,
@@ -103,6 +105,22 @@ export const MessageContextMenu = ({
)}
</>
)}
{onEndPoll && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__end-poll',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onEndPoll();
}}
>
{i18n('icu:Poll__end-poll')}
</MenuItem>
)}
{onForward && (
<MenuItem
attributes={{

View File

@@ -92,6 +92,7 @@ export type PropsReduxActions = Pick<
| 'checkForAccount'
| 'clearTargetedMessage'
| 'doubleCheckMissingQuoteReference'
| 'endPoll'
| 'kickOffAttachmentDownload'
| 'markAttachmentAsCorrupted'
| 'messageExpanded'
@@ -137,6 +138,7 @@ export function MessageDetail({
clearTargetedMessage,
contactNameColor,
doubleCheckMissingQuoteReference,
endPoll,
getPreferredBadge,
i18n,
interactionMode,
@@ -346,6 +348,7 @@ export function MessageDetail({
displayLimit={Number.MAX_SAFE_INTEGER}
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
endPoll={endPoll}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
interactionMode={interactionMode}

View File

@@ -0,0 +1,53 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../../types/Util.std.js';
import type { ConversationType } from '../../state/ducks/conversations.preload.js';
import { SystemMessage } from './SystemMessage.dom.js';
import { Button, ButtonVariant, ButtonSize } from '../Button.dom.js';
export type PropsType = {
sender: ConversationType;
pollQuestion: string;
pollMessageId: string;
conversationId: string;
i18n: LocalizerType;
scrollToPollMessage: (messageId: string, conversationId: string) => unknown;
};
export function PollTerminateNotification({
sender,
pollQuestion,
pollMessageId,
conversationId,
i18n,
scrollToPollMessage,
}: PropsType): JSX.Element {
const message = sender.isMe
? i18n('icu:PollTerminate--you', { poll: pollQuestion })
: i18n('icu:PollTerminate--other', {
name: sender.title,
poll: pollQuestion,
});
const handleViewPoll = () => {
scrollToPollMessage(pollMessageId, conversationId);
};
return (
<SystemMessage
symbol="poll"
contents={message}
button={
<Button
onClick={handleViewPoll}
variant={ButtonVariant.SystemMessage}
size={ButtonSize.Small}
>
{i18n('icu:PollTerminate__view-poll')}
</Button>
}
/>
);
}

View File

@@ -75,6 +75,7 @@ const defaultMessageProps: TimelineMessagesProps = {
}),
canCopy: true,
canEditMessage: true,
canEndPoll: false,
canForward: true,
canReact: true,
canReply: true,
@@ -116,6 +117,7 @@ const defaultMessageProps: TimelineMessagesProps = {
openGiftBadge: action('openGiftBadge'),
previews: [],
reactToMessage: action('default--reactToMessage'),
endPoll: action('default--endPoll'),
readStatus: ReadStatus.Read,
renderReactionPicker: () => <div />,
renderAudioAttachment: () => <div>*AudioAttachment*</div>,

View File

@@ -4,6 +4,9 @@
import type { ReactNode } from 'react';
import React, { forwardRef } from 'react';
import classNames from 'classnames';
import { AxoSymbol } from '../../axo/AxoSymbol.dom.js';
import type { AxoSymbolIconName } from '../../axo/_internal/AxoSymbolDefs.generated.std.js';
import { tw } from '../../axo/tw.dom.js';
export enum SystemMessageKind {
Normal = 'Normal',
@@ -11,50 +14,62 @@ export enum SystemMessageKind {
Error = 'Error',
}
export type PropsType = {
icon:
| 'audio-incoming'
| 'audio-missed'
| 'audio-outgoing'
| 'block'
| 'group'
| 'group-access'
| 'group-add'
| 'group-approved'
| 'group-avatar'
| 'group-decline'
| 'group-edit'
| 'group-leave'
| 'group-remove'
| 'group-summary'
| 'info'
| 'phone'
| 'profile'
| 'safety-number'
| 'spam'
| 'session-refresh'
| 'thread'
| 'timer'
| 'timer-disabled'
| 'unsupported'
| 'unsupported--can-process'
| 'verified'
| 'verified-not'
| 'video'
| 'video-incoming'
| 'video-missed'
| 'video-outgoing'
| 'warning'
| 'payment-event'
| 'merge';
type SystemMessageBaseProps = {
contents: ReactNode;
button?: ReactNode;
kind?: SystemMessageKind;
};
export type PropsType = SystemMessageBaseProps &
(
| {
/** @deprecated Use symbol instead */
icon:
| 'audio-incoming'
| 'audio-missed'
| 'audio-outgoing'
| 'block'
| 'group'
| 'group-access'
| 'group-add'
| 'group-approved'
| 'group-avatar'
| 'group-decline'
| 'group-edit'
| 'group-leave'
| 'group-remove'
| 'group-summary'
| 'info'
| 'phone'
| 'profile'
| 'safety-number'
| 'spam'
| 'session-refresh'
| 'thread'
| 'timer'
| 'timer-disabled'
| 'unsupported'
| 'unsupported--can-process'
| 'verified'
| 'verified-not'
| 'video'
| 'video-incoming'
| 'video-missed'
| 'video-outgoing'
| 'warning'
| 'payment-event'
| 'merge';
symbol?: never;
}
| {
icon?: never;
symbol: AxoSymbolIconName;
}
);
export const SystemMessage = forwardRef<HTMLDivElement, PropsType>(
function SystemMessageInner(
{ icon, contents, button, kind = SystemMessageKind.Normal },
{ icon, symbol, contents, button, kind = SystemMessageKind.Normal },
ref
) {
return (
@@ -69,9 +84,15 @@ export const SystemMessage = forwardRef<HTMLDivElement, PropsType>(
<div
className={classNames(
'SystemMessage__contents',
`SystemMessage__contents--icon-${icon}`
icon && 'SystemMessage__contents--has-icon',
icon && `SystemMessage__contents--icon-${icon}`
)}
>
{symbol && (
<span className={tw('me-2 inline-block')}>
<AxoSymbol.Icon size={16} symbol={symbol} label={null} />
</span>
)}
{contents}
</div>
{button && (

View File

@@ -53,6 +53,7 @@ function mockMessageTimelineItem(
canDeleteForEveryone: false,
canDownload: true,
canEditMessage: true,
canEndPoll: false,
canForward: true,
canReact: true,
canReply: true,
@@ -286,6 +287,7 @@ const actions = () => ({
clearTargetedMessage: action('clearTargetedMessage'),
updateSharedGroups: action('updateSharedGroups'),
endPoll: action('endPoll'),
reactToMessage: action('reactToMessage'),
setMessageToEdit: action('setMessageToEdit'),
setQuoteByMessageId: action('setQuoteByMessageId'),
@@ -309,6 +311,7 @@ const actions = () => ({
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
openGiftBadge: action('openGiftBadge'),
scrollToPollMessage: action('scrollToPollMessage'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
showAttachmentDownloadStillInProgressToast: action(
'showAttachmentDownloadStillInProgressToast'

View File

@@ -48,6 +48,7 @@ const getDefaultProps = () => ({
platform: 'darwin',
targetMessage: action('targetMessage'),
toggleSelectMessage: action('toggleSelectMessage'),
endPoll: action('endPoll'),
reactToMessage: action('reactToMessage'),
checkForAccount: action('checkForAccount'),
clearTargetedMessage: action('clearTargetedMessage'),
@@ -91,6 +92,7 @@ const getDefaultProps = () => ({
),
showMediaNoLongerAvailableToast: action('showMediaNoLongerAvailableToast'),
showTapToViewNotAvailableModal: action('showTapToViewNotAvailableModal'),
scrollToPollMessage: action('scrollToPollMessage'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
showSpoiler: action('showSpoiler'),
startConversation: action('startConversation'),

View File

@@ -52,6 +52,8 @@ import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileC
import { ProfileChangeNotification } from './ProfileChangeNotification.dom.js';
import type { PropsType as PaymentEventNotificationPropsType } from './PaymentEventNotification.dom.js';
import { PaymentEventNotification } from './PaymentEventNotification.dom.js';
import type { PropsType as PollTerminateNotificationPropsType } from './PollTerminateNotification.dom.js';
import { PollTerminateNotification } from './PollTerminateNotification.dom.js';
import type { PropsDataType as ConversationMergeNotificationPropsType } from './ConversationMergeNotification.dom.js';
import { ConversationMergeNotification } from './ConversationMergeNotification.dom.js';
import type { PropsDataType as PhoneNumberDiscoveryNotificationPropsType } from './PhoneNumberDiscoveryNotification.dom.js';
@@ -152,6 +154,13 @@ type MessageRequestResponseNotificationType = {
type: 'messageRequestResponse';
data: MessageRequestResponseNotificationData;
};
type PollTerminateNotificationType = {
type: 'pollTerminate';
data: Omit<
PollTerminateNotificationPropsType,
'i18n' | 'scrollToPollMessage'
>;
};
export type TimelineItemType = (
| CallHistoryType
@@ -165,6 +174,7 @@ export type TimelineItemType = (
| JoinedSignalNotificationType
| MessageType
| PhoneNumberDiscoveryNotificationType
| PollTerminateNotificationType
| ProfileChangeNotificationType
| ResetSessionNotificationType
| SafetyNumberNotificationType
@@ -187,6 +197,7 @@ type PropsLocalType = {
isGroup: boolean;
isNextItemCallingNotification: boolean;
isTargeted: boolean;
scrollToPollMessage: (messageId: string, conversationId: string) => unknown;
targetMessage: (messageId: string, conversationId: string) => unknown;
shouldRenderDateHeader: boolean;
onOpenEditNicknameAndNoteModal: (contactId: string) => void;
@@ -235,6 +246,7 @@ export const TimelineItem = memo(function TimelineItem({
platform,
renderUniversalTimerNotification,
returnToActiveCall,
scrollToPollMessage,
targetMessage,
setMessageToEdit,
shouldCollapseAbove,
@@ -413,6 +425,15 @@ export const TimelineItem = memo(function TimelineItem({
i18n={i18n}
/>
);
} else if (item.type === 'pollTerminate') {
notification = (
<PollTerminateNotification
{...reducedProps}
{...item.data}
i18n={i18n}
scrollToPollMessage={scrollToPollMessage}
/>
);
} else if (item.type === 'messageRequestResponse') {
notification = (
<MessageRequestResponseNotification

View File

@@ -235,6 +235,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
bodyRanges: overrideProps.bodyRanges,
canCopy: true,
canEditMessage: true,
canEndPoll: overrideProps.direction === 'outgoing',
canReact: true,
canReply: true,
canDownload: true,
@@ -294,6 +295,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
previews: overrideProps.previews || [],
quote: overrideProps.quote || undefined,
reactions: overrideProps.reactions,
endPoll: action('endPoll'),
reactToMessage: action('reactToMessage'),
readStatus:
overrideProps.readStatus === undefined
@@ -384,6 +386,7 @@ const renderBothDirections = (props: Props) => (
...props,
author: { ...props.author, id: getDefaultConversation().id },
direction: 'outgoing',
canEndPoll: true,
})}
</>
);

View File

@@ -57,6 +57,7 @@ export type PropsData = {
canDownload: boolean;
canCopy: boolean;
canEditMessage: boolean;
canEndPoll: boolean;
canForward: boolean;
canRetry: boolean;
canRetryDeleteForEveryone: boolean;
@@ -70,6 +71,7 @@ export type PropsActions = {
pushPanelForConversation: PushPanelForConversationActionType;
toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void;
toggleForwardMessagesModal: (payload: ForwardMessagesPayload) => void;
endPoll: (id: string) => void;
reactToMessage: (
id: string,
{ emoji, remove }: { emoji: string; remove: boolean }
@@ -109,6 +111,7 @@ export function TimelineMessage(props: Props): JSX.Element {
canDownload,
canCopy,
canEditMessage,
canEndPoll,
canForward,
canReact,
canReply,
@@ -123,6 +126,7 @@ export function TimelineMessage(props: Props): JSX.Element {
isTargeted,
kickOffAttachmentDownload,
copyMessageText,
endPoll,
pushPanelForConversation,
reactToMessage,
renderReactionPicker,
@@ -400,6 +404,7 @@ export function TimelineMessage(props: Props): JSX.Element {
}
onReplyToMessage={handleReplyToMessage}
onReact={handleReact}
onEndPoll={canEndPoll ? () => endPoll(id) : undefined}
onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined}
onRetryDeleteForEveryone={
canRetryDeleteForEveryone

View File

@@ -88,6 +88,8 @@ export type PollMessageContentsProps = {
messageId: string;
optionIndexes: ReadonlyArray<number>;
}) => void;
endPoll: (messageId: string) => void;
canEndPoll?: boolean;
};
export function PollMessageContents({
@@ -96,6 +98,8 @@ export function PollMessageContents({
i18n,
messageId,
sendPollVote,
endPoll,
canEndPoll,
}: PollMessageContentsProps): JSX.Element {
const [showVotesModal, setShowVotesModal] = useState(false);
const isIncoming = direction === 'incoming';
@@ -268,6 +272,9 @@ export function PollMessageContents({
i18n={i18n}
poll={poll}
onClose={() => setShowVotesModal(false)}
endPoll={endPoll}
canEndPoll={canEndPoll}
messageId={messageId}
/>
)}
</div>

View File

@@ -3,6 +3,7 @@
import React from 'react';
import { tw } from '../../../axo/tw.dom.js';
import { AxoButton } from '../../../axo/AxoButton.dom.js';
import { Modal } from '../../Modal.dom.js';
import { Avatar, AvatarSize } from '../../Avatar.dom.js';
import { ContactName } from '../ContactName.dom.js';
@@ -16,12 +17,18 @@ type PollVotesModalProps = {
i18n: LocalizerType;
poll: PollWithResolvedVotersType;
onClose: () => void;
endPoll: (messageId: string) => void;
canEndPoll?: boolean;
messageId: string;
};
export function PollVotesModal({
i18n,
poll,
onClose,
endPoll,
canEndPoll,
messageId,
}: PollVotesModalProps): JSX.Element {
return (
<Modal
@@ -41,56 +48,64 @@ export function PollVotesModal({
<div className={tw('type-body-large')}>{poll.question}</div>
</div>
{poll.options.map((option, index) => {
{poll.options.map((option, index, array) => {
const voters = poll.votesByOption.get(index) || [];
const optionKey = `option-${index}`;
const isLastOption = index === array.length - 1;
return (
<div key={optionKey} className={tw('flex flex-col')}>
{/* Option Header */}
<div
className={tw('mb-3 flex items-start gap-3 text-label-primary')}
>
<div className={tw('type-title-small')}>{option}</div>
<React.Fragment key={optionKey}>
<div className={tw('flex flex-col')}>
{/* Option Header */}
<div
className={tw('ms-auto mt-[2px] shrink-0 type-body-medium')}
className={tw(
'mb-3 flex items-start gap-3 text-label-primary'
)}
>
{i18n('icu:PollVotesModal__voteCount', {
count: voters.length,
})}
<div className={tw('type-title-small')}>{option}</div>
<div
className={tw('ms-auto mt-[2px] shrink-0 type-body-medium')}
>
{i18n('icu:PollVotesModal__voteCount', {
count: voters.length,
})}
</div>
</div>
{/* Voters List */}
<div className={tw('flex flex-col gap-4')}>
{voters.map((vote: PollVoteWithUserType) => (
<div
key={vote.from.id}
className={tw('flex items-center gap-3')}
>
<Avatar
avatarUrl={vote.from.avatarUrl}
badge={undefined}
color={vote.from.color}
conversationType="direct"
i18n={i18n}
noteToSelf={false}
phoneNumber={vote.from.phoneNumber}
profileName={vote.from.profileName}
sharedGroupNames={vote.from.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
title={vote.from.title}
/>
<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>
{/* Voters List */}
<div className={tw('flex flex-col gap-4')}>
{voters.map((vote: PollVoteWithUserType) => (
<div
key={vote.from.id}
className={tw('flex items-center gap-3')}
>
<Avatar
avatarUrl={vote.from.avatarUrl}
badge={undefined}
color={vote.from.color}
conversationType="direct"
i18n={i18n}
noteToSelf={false}
phoneNumber={vote.from.phoneNumber}
profileName={vote.from.profileName}
sharedGroupNames={vote.from.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
title={vote.from.title}
/>
<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>
{!isLastOption && (
<hr className={tw('border-t-[0.5px] border-label-secondary')} />
)}
</React.Fragment>
);
})}
@@ -104,6 +119,24 @@ export function PollVotesModal({
{i18n('icu:PollVotesModal__noVotes')}
</div>
)}
{!poll.terminatedAt && canEndPoll && (
<>
<hr className={tw('border-t-[0.5px] border-label-secondary')} />
<div className={tw('flex justify-center')}>
<AxoButton.Root
size="lg"
variant="secondary"
onClick={() => {
endPoll(messageId);
onClose();
}}
>
{i18n('icu:Poll__end-poll')}
</AxoButton.Root>
</div>
</>
)}
</div>
</Modal>
);

View File

@@ -20,6 +20,7 @@ import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone.preload.j
import { sendDeleteStoryForEveryone } from './helpers/sendDeleteStoryForEveryone.preload.js';
import { sendProfileKey } from './helpers/sendProfileKey.preload.js';
import { sendReaction } from './helpers/sendReaction.preload.js';
import { sendPollTerminate } from './helpers/sendPollTerminate.preload.js';
import { sendPollVote } from './helpers/sendPollVote.preload.js';
import { sendStory } from './helpers/sendStory.preload.js';
import { sendReceipts } from './helpers/sendReceipts.preload.js';
@@ -70,10 +71,11 @@ export const conversationQueueJobEnum = z.enum([
'GroupUpdate',
'NormalMessage',
'NullMessage',
'PollTerminate',
'PollVote',
'ProfileKey',
'ProfileKeyForCall',
'Reaction',
'PollVote',
'ResendRequest',
'SavedProto',
'SenderKeyDistribution',
@@ -206,6 +208,15 @@ const pollVoteJobDataSchema = z.object({
});
export type PollVoteJobData = z.infer<typeof pollVoteJobDataSchema>;
const pollTerminateJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.PollTerminate),
conversationId: z.string(),
pollMessageId: z.string(),
targetTimestamp: z.number(),
revision: z.number().optional(),
});
export type PollTerminateJobData = z.infer<typeof pollTerminateJobDataSchema>;
const resendRequestJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.ResendRequest),
conversationId: z.string(),
@@ -268,9 +279,10 @@ export const conversationQueueJobDataSchema = z.union([
groupUpdateJobDataSchema,
normalMessageSendJobDataSchema,
nullMessageJobDataSchema,
pollTerminateJobDataSchema,
pollVoteJobDataSchema,
profileKeyJobDataSchema,
reactionJobDataSchema,
pollVoteJobDataSchema,
resendRequestJobDataSchema,
savedProtoJobDataSchema,
senderKeyDistributionJobDataSchema,
@@ -327,8 +339,11 @@ function shouldSendShowCaptcha(type: ConversationQueueJobEnum): boolean {
if (type === 'Reaction') {
return false;
}
if (type === 'PollTerminate') {
return true;
}
if (type === 'PollVote') {
return false;
return true;
}
if (type === 'Receipts') {
return false;
@@ -974,6 +989,9 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
case jobSet.Reaction:
await sendReaction(conversation, jobBundle, data);
break;
case jobSet.PollTerminate:
await sendPollTerminate(conversation, jobBundle, data);
break;
case jobSet.PollVote:
await sendPollVote(conversation, jobBundle, data);
break;

View File

@@ -0,0 +1,206 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ContentHint } from '@signalapp/libsignal-client';
import lodash from 'lodash';
import type { ConversationModel } from '../../models/conversations.preload.js';
import type {
ConversationQueueJobBundle,
PollTerminateJobData,
} from '../conversationJobQueue.preload.js';
import { PollTerminateSendStatus } from '../../types/Polls.dom.js';
import { wrapWithSyncMessageSend } from '../../util/wrapWithSyncMessageSend.preload.js';
import { sendContentMessageToGroup } from '../../util/sendToGroup.preload.js';
import { getMessageById } from '../../messages/getMessageById.preload.js';
import { ourProfileKeyService } from '../../services/ourProfileKey.std.js';
import { getSendOptions } from '../../util/getSendOptions.preload.js';
import { isGroupV2 } from '../../util/whatTypeOfConversation.dom.js';
import {
handleMultipleSendErrors,
maybeExpandErrors,
} from './handleMultipleSendErrors.std.js';
import { getRecipients } from '../../util/getRecipients.dom.js';
import type { MessageModel } from '../../models/messages.preload.js';
import type { LoggerType } from '../../types/Logging.std.js';
import { strictAssert } from '../../util/assert.std.js';
import { DataWriter } from '../../sql/Client.preload.js';
import { cleanupMessages } from '../../util/cleanup.preload.js';
const { isNumber } = lodash;
export async function sendPollTerminate(
conversation: ConversationModel,
{
isFinalAttempt,
messaging,
shouldContinue,
timeRemaining,
log: jobLog,
}: ConversationQueueJobBundle,
data: PollTerminateJobData
): Promise<void> {
const { pollMessageId, targetTimestamp, revision } = data;
const logId = `sendPollTerminate(${conversation.idForLogging()}, ${pollMessageId})`;
const pollMessage = await getMessageById(pollMessageId);
if (!pollMessage) {
jobLog.error(`${logId}: Failed to fetch poll message. Failing job.`);
return;
}
const poll = pollMessage.get('poll');
if (!poll) {
jobLog.error(`${logId}: Message has no poll object. Failing job.`);
await markTerminateFailed(pollMessage, jobLog);
return;
}
if (!isGroupV2(conversation.attributes)) {
jobLog.error(`${logId}: Non-GroupV2 conversation. Failing job.`);
return;
}
if (!shouldContinue) {
jobLog.info(`${logId}: Ran out of time. Giving up on sending`);
await markTerminateFailed(pollMessage, jobLog);
return;
}
const recipients = getRecipients(conversation.attributes);
await conversation.queueJob(
'conversationQueue/sendPollTerminate',
async abortSignal => {
jobLog.info(
`${logId}: Sending poll terminate for poll timestamp ${targetTimestamp}`
);
const profileKey = conversation.get('profileSharing')
? await ourProfileKeyService.get()
: undefined;
const sendOptions = await getSendOptions(conversation.attributes);
try {
if (isGroupV2(conversation.attributes) && !isNumber(revision)) {
jobLog.error('No revision provided, but conversation is GroupV2');
}
const groupV2Info = conversation.getGroupV2Info({
members: recipients,
});
if (groupV2Info && isNumber(revision)) {
groupV2Info.revision = revision;
}
strictAssert(groupV2Info, 'could not get group info from conversation');
const timestamp = Date.now();
const expireTimer = conversation.get('expireTimer');
const contentMessage = await messaging.getPollTerminateContentMessage({
groupV2: groupV2Info,
timestamp,
profileKey,
expireTimer,
expireTimerVersion: conversation.getExpireTimerVersion(),
pollTerminate: {
targetTimestamp,
},
});
if (abortSignal?.aborted) {
throw new Error('sendPollTerminate was aborted');
}
await wrapWithSyncMessageSend({
conversation,
logId,
messageIds: [pollMessageId],
send: async () =>
sendContentMessageToGroup({
contentHint: ContentHint.Resendable,
contentMessage,
messageId: pollMessageId,
recipients,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType: 'pollTerminate',
timestamp,
urgent: true,
}),
sendType: 'pollTerminate',
timestamp,
});
await markTerminateSuccess(pollMessage, jobLog);
} catch (error: unknown) {
const errors = maybeExpandErrors(error);
await handleMultipleSendErrors({
errors,
isFinalAttempt,
log: jobLog,
markFailed: () => markTerminateFailed(pollMessage, jobLog),
timeRemaining,
toThrow: error,
});
}
}
);
}
async function markTerminateSuccess(
message: MessageModel,
log: LoggerType
): Promise<void> {
log.info('markTerminateSuccess: Poll terminate sent successfully');
const poll = message.get('poll');
if (poll) {
message.set({
poll: {
...poll,
terminateSendStatus: PollTerminateSendStatus.Complete,
},
});
await window.MessageCache.saveMessage(message.attributes);
}
}
async function markTerminateFailed(
message: MessageModel,
log: LoggerType
): Promise<void> {
log.error('markTerminateFailed: Poll terminate send failed');
const poll = message.get('poll');
if (!poll) {
return;
}
message.set({
poll: {
...poll,
terminatedAt: undefined,
terminateSendStatus: PollTerminateSendStatus.Failed,
},
});
await window.MessageCache.saveMessage(message.attributes);
// Delete the poll-terminate chat event from timeline
if (poll.terminatedAt) {
const notificationMessage = await window.MessageCache.findBySentAt(
poll.terminatedAt,
m =>
m.get('type') === 'poll-terminate' &&
m.get('pollTerminateNotification')?.pollMessageId === message.id
);
if (notificationMessage) {
log.info('markTerminateFailed: Deleting poll-terminate notification');
await DataWriter.removeMessage(notificationMessage.id, {
cleanupMessages,
});
}
}
}

View File

@@ -254,7 +254,7 @@ export async function sendPollVote(
}
);
await send(ephemeral, {
const messageSendPromise = send(ephemeral, {
promise: handleMessageSend(promise, {
messageIds: [pollMessageId],
sendType: 'pollVote',
@@ -263,7 +263,9 @@ export async function sendPollVote(
targetTimestamp: currentTimestamp,
});
// Await the inner promise to get SendMessageProtoError for upstream processors
// Because message.send swallows and processes errors, we'll await the inner promise
// to get the SendMessageProtoError, which gives us information upstream
// processors need to detect certain kinds of situations.
try {
await promise;
} catch (error) {
@@ -278,6 +280,8 @@ export async function sendPollVote(
}
}
await messageSendPromise;
// Check if the send fully succeeded
ephemeralSendStateByConversationId =
ephemeral.get('sendStateByConversationId') || {};

View File

@@ -7,6 +7,7 @@ import type {
ReadonlyMessageAttributesType,
} from '../model-types.d.ts';
import type { MessagePollVoteType } from '../types/Polls.dom.js';
import { PollTerminateSendStatus } from '../types/Polls.dom.js';
import { MessageModel } from '../models/messages.preload.js';
import { DataReader } from '../sql/Client.preload.js';
import * as Errors from '../types/errors.std.js';
@@ -523,10 +524,16 @@ export async function handlePollTerminate(
return;
}
const isFromThisDevice = terminate.source === PollSource.FromThisDevice;
message.set({
poll: {
...poll,
terminatedAt: terminate.timestamp,
// Track send status (only for our own terminates)
terminateSendStatus: isFromThisDevice
? PollTerminateSendStatus.Pending
: PollTerminateSendStatus.NotInitiated,
},
});
@@ -537,6 +544,15 @@ export async function handlePollTerminate(
if (shouldPersist) {
await window.MessageCache.saveMessage(message.attributes);
await conversation.addPollTerminateNotification({
pollQuestion: poll.question,
pollMessageId: message.id,
terminatorId: terminate.fromConversationId,
timestamp: terminate.timestamp,
isMeTerminating: isMe(author.attributes),
});
window.reduxActions.conversations.markOpenConversationRead(conversation.id);
}
}

5
ts/model-types.d.ts vendored
View File

@@ -166,6 +166,7 @@ type MessageType =
| 'keychange'
| 'outgoing'
| 'phone-number-discovery'
| 'poll-terminate'
| 'profile-change'
| 'story'
| 'timer-notification'
@@ -212,6 +213,10 @@ export type MessageAttributesType = {
quote?: QuotedMessageType;
reactions?: ReadonlyArray<MessageReactionType>;
poll?: PollMessageAttribute;
pollTerminateNotification?: {
question: string;
pollMessageId: string;
};
requiredProtocolVersion?: number;
sms?: boolean;
sourceDevice?: number;

View File

@@ -3495,6 +3495,44 @@ export class ConversationModel {
}
}
async addPollTerminateNotification(params: {
pollQuestion: string;
pollMessageId: string;
terminatorId: string;
timestamp: number;
isMeTerminating: boolean;
}): Promise<void> {
const terminatorConversation = window.ConversationController.get(
params.terminatorId
);
const terminatorServiceId = terminatorConversation?.getServiceId();
const message = new MessageModel({
...generateMessageId(incrementMessageCounter()),
conversationId: this.id,
type: 'poll-terminate',
sent_at: params.timestamp,
timestamp: params.timestamp,
received_at_ms: params.timestamp,
sourceServiceId: terminatorServiceId,
pollTerminateNotification: {
question: params.pollQuestion,
pollMessageId: params.pollMessageId,
},
readStatus: params.isMeTerminating ? ReadStatus.Read : ReadStatus.Unread,
seenStatus: params.isMeTerminating ? SeenStatus.Seen : SeenStatus.Unseen,
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
});
await window.MessageCache.saveMessage(message, { forceSave: true });
window.MessageCache.register(message);
drop(this.onNewMessage(message));
this.throttledUpdateUnread();
await maybeNotify({ message: message.attributes, conversation: this });
}
async addNotification(
type: MessageAttributesType['type'],
extra: Partial<MessageAttributesType> = {}

View File

@@ -0,0 +1,74 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v7 as generateUuid } from 'uuid';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue.preload.js';
import { getMessageById } from '../messages/getMessageById.preload.js';
import {
handlePollTerminate,
PollSource,
type PollTerminateAttributesType,
} from '../messageModifiers/Polls.preload.js';
import { isGroup } from '../util/whatTypeOfConversation.dom.js';
import { strictAssert } from '../util/assert.std.js';
import { createLogger } from '../logging/log.std.js';
const log = createLogger('enqueuePollTerminateForSend');
export async function enqueuePollTerminateForSend({
messageId,
}: Readonly<{
messageId: string;
}>): Promise<void> {
const message = await getMessageById(messageId);
strictAssert(message, 'enqueuePollTerminateForSend: no message found');
const conversation = window.ConversationController.get(
message.get('conversationId')
);
strictAssert(
conversation,
'enqueuePollTerminateForSend: No conversation extracted from target message'
);
strictAssert(
isGroup(conversation.attributes),
'enqueuePollTerminateForSend: conversation must be a group'
);
const ourId = window.ConversationController.getOurConversationIdOrThrow();
const timestamp = Date.now();
const targetTimestamp = message.get('sent_at');
const terminate: PollTerminateAttributesType = {
envelopeId: generateUuid(),
removeFromMessageReceiverCache: () => undefined,
fromConversationId: ourId,
source: PollSource.FromThisDevice,
targetTimestamp,
receivedAtDate: timestamp,
timestamp,
};
await handlePollTerminate(message, terminate, { shouldPersist: true });
await conversationJobQueue.add(
{
type: conversationQueueJobEnum.enum.PollTerminate,
conversationId: conversation.id,
pollMessageId: messageId,
targetTimestamp,
revision: conversation.get('revision'),
},
async jobToInsert => {
log.info(
`Enqueueing poll terminate for poll ${messageId} with job ${jobToInsert.id}`
);
await window.MessageCache.saveMessage(message.attributes, {
jobToInsert,
});
}
);
}

View File

@@ -77,6 +77,7 @@ import { canReply, isNormalBubble } from '../selectors/message.preload.js';
import { getAuthorId } from '../../messages/sources.preload.js';
import { getConversationSelector } from '../selectors/conversations.dom.js';
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend.preload.js';
import { enqueuePollTerminateForSend } from '../../polls/enqueuePollTerminateForSend.preload.js';
import { useBoundActions } from '../../hooks/useBoundActions.std.js';
import {
CONVERSATION_UNLOADED,
@@ -242,6 +243,7 @@ export const actions = {
addAttachment,
addPendingAttachment,
cancelJoinRequest,
endPoll,
incrementSendCounter,
onClearAttachments,
onCloseLinkPreview,
@@ -253,6 +255,7 @@ export const actions = {
replaceAttachments,
resetComposer,
saveDraftRecordingIfNeeded,
scrollToPollMessage,
scrollToQuotedMessage,
sendEditedMessage,
sendMultiMediaMessage,
@@ -374,6 +377,40 @@ function scrollToQuotedMessage({
};
}
function scrollToPollMessage(
pollMessageId: string,
conversationId: string
): ThunkAction<
void,
RootStateType,
unknown,
ShowToastActionType | ScrollToMessageActionType
> {
return async (dispatch, getState) => {
const pollMessage = await getMessageById(pollMessageId);
if (!pollMessage) {
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.PollNotFound,
},
});
return;
}
if (getState().conversations.selectedConversationId !== conversationId) {
return;
}
scrollToMessage(conversationId, pollMessageId)(
dispatch,
getState,
undefined
);
};
}
export function saveDraftRecordingIfNeeded(): ThunkAction<
void,
RootStateType,
@@ -1349,6 +1386,33 @@ function reactToMessage(
};
}
function endPoll(
messageId: string
): ThunkAction<
void,
RootStateType,
unknown,
NoopActionType | ShowToastActionType
> {
return async dispatch => {
try {
await enqueuePollTerminateForSend({ messageId });
dispatch({
type: 'NOOP',
payload: null,
});
} catch (error) {
log.error('endPoll: Error sending poll terminate', error, messageId);
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.Error,
},
});
}
};
}
export function resetComposer(conversationId: string): ResetComposerActionType {
return {
type: RESET_COMPOSER,

View File

@@ -889,6 +889,7 @@ export const getPropsForMessage = (
canEditMessage: canEditMessage(message),
canDeleteForEveryone: canDeleteForEveryone(message, conversation.isMe),
canDownload: canDownload(message, conversationSelector),
canEndPoll: canEndPoll(message),
canForward: canForward(message),
canReact: canReact(message, ourConversationId, conversationSelector),
canReply: canReply(message, ourConversationId, conversationSelector),
@@ -1068,6 +1069,13 @@ export function getPropsForBubble(
timestamp,
};
}
if (isPollTerminate(message)) {
return {
type: 'pollTerminate',
data: getPropsForPollTerminate(message, options),
timestamp,
};
}
if (isUniversalTimerNotification(message)) {
return {
type: 'universalTimerNotification',
@@ -1679,6 +1687,35 @@ function getPropsForProfileChange(
} as ProfileChangeNotificationPropsType;
}
// Poll Terminate
export function isPollTerminate(message: MessageWithUIFieldsType): boolean {
return message.type === 'poll-terminate';
}
function getPropsForPollTerminate(
message: MessageWithUIFieldsType,
{ conversationSelector }: GetPropsForBubbleOptions
) {
const { pollTerminateNotification, sourceServiceId, conversationId } =
message;
if (!pollTerminateNotification) {
throw new Error(
'getPropsForPollTerminate: pollTerminateNotification is undefined'
);
}
const sender = conversationSelector(sourceServiceId);
return {
sender,
pollQuestion: pollTerminateNotification.question,
pollMessageId: pollTerminateNotification.pollMessageId,
conversationId,
};
}
// Message Request Response Event
export function isMessageRequestResponse(
@@ -2221,6 +2258,25 @@ export function canRetryDeleteForEveryone(
);
}
export function canEndPoll(
message: Pick<MessageWithUIFieldsType, 'type' | 'poll'>
): boolean {
if (message.type !== 'outgoing') {
return false;
}
const { poll } = message;
if (!poll) {
return false;
}
if (poll.terminatedAt != null) {
return false;
}
return true;
}
export function canDownload(
message: MessageWithUIFieldsType,
conversationSelector: GetConversationByIdType

View File

@@ -17,6 +17,7 @@ import { getMessageDetails } from '../selectors/message.preload.js';
import { getPreferredBadgeSelector } from '../selectors/badges.preload.js';
import { renderAudioAttachment } from './renderAudioAttachment.preload.js';
import { useAccountsActions } from '../ducks/accounts.preload.js';
import { useComposerActions } from '../ducks/composer.preload.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
import { useGlobalModalActions } from '../ducks/globalModals.preload.js';
import { useLightboxActions } from '../ducks/lightbox.preload.js';
@@ -38,6 +39,7 @@ export const SmartMessageDetail = memo(
const messageDetails = useSelector(getMessageDetails);
const theme = useSelector(getTheme);
const { checkForAccount } = useAccountsActions();
const { endPoll } = useComposerActions();
const {
cancelAttachmentDownload,
clearTargetedMessage: clearSelectedMessage,
@@ -93,6 +95,7 @@ export const SmartMessageDetail = memo(
contactNameColor={contactNameColor}
contacts={contacts}
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
endPoll={endPoll}
errors={errors}
getPreferredBadge={getPreferredBadge}
i18n={i18n}

View File

@@ -147,8 +147,13 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
toggleSelectMessage,
} = useConversationsActions();
const { reactToMessage, scrollToQuotedMessage, setQuoteByMessageId } =
useComposerActions();
const {
endPoll,
reactToMessage,
scrollToPollMessage,
scrollToQuotedMessage,
setQuoteByMessageId,
} = useComposerActions();
const {
showContactModal,
@@ -218,6 +223,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
messageExpanded={messageExpanded}
openGiftBadge={openGiftBadge}
pushPanelForConversation={pushPanelForConversation}
endPoll={endPoll}
reactToMessage={reactToMessage}
copyMessageText={copyMessageText}
onOpenEditNicknameAndNoteModal={onOpenEditNicknameAndNoteModal}
@@ -232,6 +238,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
returnToActiveCall={returnToActiveCall}
saveAttachment={saveAttachment}
saveAttachments={saveAttachments}
scrollToPollMessage={scrollToPollMessage}
scrollToQuotedMessage={scrollToQuotedMessage}
targetMessage={targetMessage}
setQuoteByMessageId={setQuoteByMessageId}

View File

@@ -217,6 +217,9 @@ export type MessageOptionsType = {
reaction?: ReactionType;
pollVote?: OutgoingPollVote;
pollCreate?: PollCreateType;
pollTerminate?: Readonly<{
targetTimestamp: number;
}>;
deletedForEveryoneTimestamp?: number;
targetTimestampForEdit?: number;
timestamp: number;
@@ -243,6 +246,9 @@ export type GroupSendOptionsType = {
timestamp: number;
pollVote?: OutgoingPollVote;
pollCreate?: PollCreateType;
pollTerminate?: Readonly<{
targetTimestamp: number;
}>;
};
export type PollVoteBuildOptions = Required<
@@ -250,6 +256,11 @@ export type PollVoteBuildOptions = Required<
> &
Pick<MessageOptionsType, 'profileKey' | 'expireTimer' | 'expireTimerVersion'>;
export type PollTerminateBuildOptions = Required<
Pick<MessageOptionsType, 'groupV2' | 'timestamp' | 'pollTerminate'>
> &
Pick<MessageOptionsType, 'profileKey' | 'expireTimer' | 'expireTimerVersion'>;
class Message {
attachments: ReadonlyArray<Proto.IAttachmentPointer>;
@@ -288,6 +299,10 @@ class Message {
pollCreate?: PollCreateType;
pollTerminate?: Readonly<{
targetTimestamp: number;
}>;
timestamp: number;
dataMessage?: Proto.DataMessage;
@@ -318,6 +333,7 @@ class Message {
this.sticker = options.sticker;
this.reaction = options.reaction;
this.pollCreate = options.pollCreate;
this.pollTerminate = options.pollTerminate;
this.timestamp = options.timestamp;
this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp;
this.groupCallUpdate = options.groupCallUpdate;
@@ -886,6 +902,62 @@ export class MessageSender {
return contentMessage;
}
createDataMessageProtoForPollTerminate({
groupV2,
timestamp,
profileKey,
expireTimer,
expireTimerVersion,
pollTerminate,
}: PollTerminateBuildOptions): Proto.DataMessage {
const dataMessage = new Proto.DataMessage();
dataMessage.timestamp = Long.fromNumber(timestamp);
const groupContext = new Proto.GroupContextV2();
groupContext.masterKey = groupV2.masterKey;
groupContext.revision = groupV2.revision;
dataMessage.groupV2 = groupContext;
if (typeof expireTimer !== 'undefined') {
dataMessage.expireTimer = expireTimer;
}
if (typeof expireTimerVersion !== 'undefined') {
dataMessage.expireTimerVersion = expireTimerVersion;
}
if (profileKey) {
dataMessage.profileKey = profileKey;
}
const terminate = new Proto.DataMessage.PollTerminate();
terminate.targetSentTimestamp = Long.fromNumber(
pollTerminate.targetTimestamp
);
dataMessage.pollTerminate = terminate;
return dataMessage;
}
async getPollTerminateContentMessage({
groupV2,
timestamp,
profileKey,
expireTimer,
expireTimerVersion,
pollTerminate,
}: PollTerminateBuildOptions): Promise<Proto.Content> {
const dataMessage = this.createDataMessageProtoForPollTerminate({
groupV2,
timestamp,
profileKey,
expireTimer,
expireTimerVersion,
pollTerminate,
});
const contentMessage = new Proto.Content();
contentMessage.dataMessage = dataMessage;
return contentMessage;
}
async getStoryMessage({
allowsReplies,
bodyRanges,
@@ -1056,6 +1128,7 @@ export class MessageSender {
timestamp,
pollVote,
pollCreate,
pollTerminate,
} = options;
if (!groupV2) {
@@ -1101,6 +1174,7 @@ export class MessageSender {
timestamp,
pollVote,
pollCreate,
pollTerminate,
};
}

View File

@@ -84,6 +84,13 @@ export const PollTerminateSchema = z
})
.describe('PollTerminate');
export enum PollTerminateSendStatus {
NotInitiated = 'NotInitiated',
Pending = 'Pending',
Complete = 'Complete',
Failed = 'Failed',
}
export type MessagePollVoteType = {
fromConversationId: string;
optionIndexes: ReadonlyArray<number>;
@@ -98,6 +105,7 @@ export type PollMessageAttribute = {
allowMultiple: boolean;
votes?: ReadonlyArray<MessagePollVoteType>;
terminatedAt?: number;
terminateSendStatus?: PollTerminateSendStatus;
};
export type PollCreateType = Pick<

View File

@@ -65,6 +65,7 @@ export enum ToastType {
NotificationProfileUpdate = 'NotificationProfileUpdate',
OriginalMessageNotFound = 'OriginalMessageNotFound',
PinnedConversationsFull = 'PinnedConversationsFull',
PollNotFound = 'PollNotFound',
ReactionFailed = 'ReactionFailed',
ReceiptSaved = 'ReceiptSaved',
ReceiptSaveFailed = 'ReceiptSaveFailed',
@@ -196,6 +197,7 @@ export type AnyToast =
}
| { toastType: ToastType.OriginalMessageNotFound }
| { toastType: ToastType.PinnedConversationsFull }
| { toastType: ToastType.PollNotFound }
| { toastType: ToastType.ReactionFailed }
| {
toastType: ToastType.ReceiptSaved;

View File

@@ -521,6 +521,25 @@ export function getNotificationDataForMessage(
};
}
const { pollTerminateNotification } = attributes;
if (pollTerminateNotification) {
const sender = findAndFormatContact(attributes.sourceServiceId);
const text = sender.isMe
? i18n('icu:PollTerminate--you', {
poll: pollTerminateNotification.question,
})
: i18n('icu:PollTerminate--other', {
name: sender.title,
poll: pollTerminateNotification.question,
});
return {
emoji: '📊',
text,
};
}
if (body) {
return {
text: body,

View File

@@ -31,6 +31,7 @@ export const sendTypesEnum = z.enum([
'expirationTimerUpdate', // non-urgent
'groupChange', // non-urgent
'reaction',
'pollTerminate',
'pollVote', // non-urgent
'typing', // excluded from send log; non-urgent

View File

@@ -44,6 +44,7 @@ export async function wrapWithSyncMessageSend({
messageIds,
sendType,
});
didSuccessfullySendOne = true;
} catch (thrown) {
if (thrown instanceof SendMessageProtoError) {