diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0adeaaf804..a7cfc70140 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/images/icons/v3/stop/stop-circle.svg b/images/icons/v3/stop/stop-circle.svg new file mode 100644 index 0000000000..2e6a8c30f1 --- /dev/null +++ b/images/icons/v3/stop/stop-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index c363dcf905..9ff9e02c53 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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( diff --git a/stylesheets/components/SystemMessage.scss b/stylesheets/components/SystemMessage.scss index 384dab6aaf..72f8fa7a03 100644 --- a/stylesheets/components/SystemMessage.scss +++ b/stylesheets/components/SystemMessage.scss @@ -53,7 +53,7 @@ } } - &::before { + &--has-icon::before { content: ''; display: inline-block; height: 16px; diff --git a/ts/components/EditHistoryMessagesModal.dom.tsx b/ts/components/EditHistoryMessagesModal.dom.tsx index 64eca22867..348d2bf398 100644 --- a/ts/components/EditHistoryMessagesModal.dom.tsx +++ b/ts/components/EditHistoryMessagesModal.dom.tsx @@ -52,6 +52,7 @@ const MESSAGE_DEFAULT_PROPS = { previews: [], retryMessageSend: shouldNeverBeCalled, sendPollVote: shouldNeverBeCalled, + endPoll: shouldNeverBeCalled, pushPanelForConversation: shouldNeverBeCalled, renderAudioAttachment: () =>
, renderingContext: 'EditHistoryMessagesModal', diff --git a/ts/components/StoryViewsNRepliesModal.dom.tsx b/ts/components/StoryViewsNRepliesModal.dom.tsx index 07336164f1..2120a53087 100644 --- a/ts/components/StoryViewsNRepliesModal.dom.tsx +++ b/ts/components/StoryViewsNRepliesModal.dom.tsx @@ -66,6 +66,7 @@ const MESSAGE_DEFAULT_PROPS = { previews: [], retryMessageSend: shouldNeverBeCalled, sendPollVote: shouldNeverBeCalled, + endPoll: shouldNeverBeCalled, pushPanelForConversation: shouldNeverBeCalled, renderAudioAttachment: () =>
, saveAttachment: shouldNeverBeCalled, diff --git a/ts/components/ToastManager.dom.stories.tsx b/ts/components/ToastManager.dom.stories.tsx index 912375683b..a80d459788 100644 --- a/ts/components/ToastManager.dom.stories.tsx +++ b/ts/components/ToastManager.dom.stories.tsx @@ -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: diff --git a/ts/components/ToastManager.dom.tsx b/ts/components/ToastManager.dom.tsx index bdc03ade2e..c2d7a1c4a8 100644 --- a/ts/components/ToastManager.dom.tsx +++ b/ts/components/ToastManager.dom.tsx @@ -652,6 +652,10 @@ export function renderToast({ ); } + if (toastType === ToastType.PollNotFound) { + return {i18n('icu:Toast--PollNotFound')}; + } + if (toastType === ToastType._InternalMainProcessLoggingError) { return ( = React.memo( onEdit={undefined} onReplyToMessage={undefined} onReact={undefined} + onEndPoll={undefined} onRetryMessageSend={undefined} onRetryDeleteForEveryone={undefined} onCopy={undefined} diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index fd444647f8..cd7f12dc7a 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -249,6 +249,7 @@ export type PropsData = { isSelectMode: boolean; isSMS: boolean; isSpoilerExpanded?: Record; + canEndPoll?: boolean; direction: DirectionType; timestamp: number; receivedAtMS?: number; @@ -363,6 +364,7 @@ export type PropsActions = { messageId: string; optionIndexes: ReadonlyArray; }) => void; + endPoll: (messageId: string) => void; showContactModal: (contactId: string, conversationId?: string) => void; showSpoiler: (messageId: string, data: Record) => void; @@ -2023,7 +2025,7 @@ export class Message extends React.PureComponent { } 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 { i18n={i18n} messageId={id} sendPollVote={this.props.sendPollVote} + endPoll={endPoll} + canEndPoll={canEndPoll} /> ); } diff --git a/ts/components/conversation/MessageContextMenu.dom.tsx b/ts/components/conversation/MessageContextMenu.dom.tsx index 66b78c6858..adf1c5f459 100644 --- a/ts/components/conversation/MessageContextMenu.dom.tsx +++ b/ts/components/conversation/MessageContextMenu.dom.tsx @@ -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 && ( + { + event.stopPropagation(); + event.preventDefault(); + + onEndPoll(); + }} + > + {i18n('icu:Poll__end-poll')} + + )} {onForward && ( 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 ( + + {i18n('icu:PollTerminate__view-poll')} + + } + /> + ); +} diff --git a/ts/components/conversation/Quote.dom.stories.tsx b/ts/components/conversation/Quote.dom.stories.tsx index 8cf247adf0..d6a89d9d00 100644 --- a/ts/components/conversation/Quote.dom.stories.tsx +++ b/ts/components/conversation/Quote.dom.stories.tsx @@ -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: () =>
, renderAudioAttachment: () =>
*AudioAttachment*
, diff --git a/ts/components/conversation/SystemMessage.dom.tsx b/ts/components/conversation/SystemMessage.dom.tsx index 8336636779..1985b50416 100644 --- a/ts/components/conversation/SystemMessage.dom.tsx +++ b/ts/components/conversation/SystemMessage.dom.tsx @@ -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( 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(
+ {symbol && ( + + + + )} {contents}
{button && ( diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index 38bb463947..9c4cebe6fe 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -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' diff --git a/ts/components/conversation/TimelineItem.dom.stories.tsx b/ts/components/conversation/TimelineItem.dom.stories.tsx index c9cd16563e..9e433e1be2 100644 --- a/ts/components/conversation/TimelineItem.dom.stories.tsx +++ b/ts/components/conversation/TimelineItem.dom.stories.tsx @@ -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'), diff --git a/ts/components/conversation/TimelineItem.dom.tsx b/ts/components/conversation/TimelineItem.dom.tsx index acdb93c566..73600b3ee7 100644 --- a/ts/components/conversation/TimelineItem.dom.tsx +++ b/ts/components/conversation/TimelineItem.dom.tsx @@ -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 = ( + + ); } else if (item.type === 'messageRequestResponse') { notification = ( = {}): 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 => ({ 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, })} ); diff --git a/ts/components/conversation/TimelineMessage.dom.tsx b/ts/components/conversation/TimelineMessage.dom.tsx index c1f0552732..23138c029b 100644 --- a/ts/components/conversation/TimelineMessage.dom.tsx +++ b/ts/components/conversation/TimelineMessage.dom.tsx @@ -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 diff --git a/ts/components/conversation/poll-message/PollMessageContents.dom.tsx b/ts/components/conversation/poll-message/PollMessageContents.dom.tsx index 4a8949fc1b..07e2e6e568 100644 --- a/ts/components/conversation/poll-message/PollMessageContents.dom.tsx +++ b/ts/components/conversation/poll-message/PollMessageContents.dom.tsx @@ -88,6 +88,8 @@ export type PollMessageContentsProps = { messageId: string; optionIndexes: ReadonlyArray; }) => 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} /> )}
diff --git a/ts/components/conversation/poll-message/PollVotesModal.dom.tsx b/ts/components/conversation/poll-message/PollVotesModal.dom.tsx index 2c371aa678..25363fe345 100644 --- a/ts/components/conversation/poll-message/PollVotesModal.dom.tsx +++ b/ts/components/conversation/poll-message/PollVotesModal.dom.tsx @@ -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 ( {poll.question}
- {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 ( -
- {/* Option Header */} -
-
{option}
+ +
+ {/* Option Header */}
- {i18n('icu:PollVotesModal__voteCount', { - count: voters.length, - })} +
{option}
+
+ {i18n('icu:PollVotesModal__voteCount', { + count: voters.length, + })} +
+
+ + {/* Voters List */} +
+ {voters.map((vote: PollVoteWithUserType) => ( +
+ +
+ +
+
+ ))}
- - {/* Voters List */} -
- {voters.map((vote: PollVoteWithUserType) => ( -
- -
- -
-
- ))} -
-
+ {!isLastOption && ( +
+ )} + ); })} @@ -104,6 +119,24 @@ export function PollVotesModal({ {i18n('icu:PollVotesModal__noVotes')}
)} + + {!poll.terminatedAt && canEndPoll && ( + <> +
+
+ { + endPoll(messageId); + onClose(); + }} + > + {i18n('icu:Poll__end-poll')} + +
+ + )} ); diff --git a/ts/jobs/conversationJobQueue.preload.ts b/ts/jobs/conversationJobQueue.preload.ts index 9d6ad67d95..168def3eb6 100644 --- a/ts/jobs/conversationJobQueue.preload.ts +++ b/ts/jobs/conversationJobQueue.preload.ts @@ -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; +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; + 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 { 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; diff --git a/ts/jobs/helpers/sendPollTerminate.preload.ts b/ts/jobs/helpers/sendPollTerminate.preload.ts new file mode 100644 index 0000000000..54f13a9beb --- /dev/null +++ b/ts/jobs/helpers/sendPollTerminate.preload.ts @@ -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 { + 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 { + 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 { + 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, + }); + } + } +} diff --git a/ts/jobs/helpers/sendPollVote.preload.ts b/ts/jobs/helpers/sendPollVote.preload.ts index 030fc3917d..16ab7175ac 100644 --- a/ts/jobs/helpers/sendPollVote.preload.ts +++ b/ts/jobs/helpers/sendPollVote.preload.ts @@ -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') || {}; diff --git a/ts/messageModifiers/Polls.preload.ts b/ts/messageModifiers/Polls.preload.ts index cbb14c0b6c..4a98f73bc8 100644 --- a/ts/messageModifiers/Polls.preload.ts +++ b/ts/messageModifiers/Polls.preload.ts @@ -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); } } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 74900c43c4..3877bb9aa9 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -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; poll?: PollMessageAttribute; + pollTerminateNotification?: { + question: string; + pollMessageId: string; + }; requiredProtocolVersion?: number; sms?: boolean; sourceDevice?: number; diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index ca68f824eb..3c5dd4c135 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -3495,6 +3495,44 @@ export class ConversationModel { } } + async addPollTerminateNotification(params: { + pollQuestion: string; + pollMessageId: string; + terminatorId: string; + timestamp: number; + isMeTerminating: boolean; + }): Promise { + 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 = {} diff --git a/ts/polls/enqueuePollTerminateForSend.preload.ts b/ts/polls/enqueuePollTerminateForSend.preload.ts new file mode 100644 index 0000000000..65b150f3c5 --- /dev/null +++ b/ts/polls/enqueuePollTerminateForSend.preload.ts @@ -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 { + 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, + }); + } + ); +} diff --git a/ts/state/ducks/composer.preload.ts b/ts/state/ducks/composer.preload.ts index 77301b6b67..77967bf5f9 100644 --- a/ts/state/ducks/composer.preload.ts +++ b/ts/state/ducks/composer.preload.ts @@ -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, diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index 7bceef9886..f54ac1fc80 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -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 +): 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 diff --git a/ts/state/smart/MessageDetail.preload.tsx b/ts/state/smart/MessageDetail.preload.tsx index 047d0c1cfa..bbee72387e 100644 --- a/ts/state/smart/MessageDetail.preload.tsx +++ b/ts/state/smart/MessageDetail.preload.tsx @@ -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} diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx index e46ad0465b..50926e3177 100644 --- a/ts/state/smart/TimelineItem.preload.tsx +++ b/ts/state/smart/TimelineItem.preload.tsx @@ -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} diff --git a/ts/textsecure/SendMessage.preload.ts b/ts/textsecure/SendMessage.preload.ts index a3b1f35e69..c354caf0fc 100644 --- a/ts/textsecure/SendMessage.preload.ts +++ b/ts/textsecure/SendMessage.preload.ts @@ -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; +export type PollTerminateBuildOptions = Required< + Pick +> & + Pick; + class Message { attachments: ReadonlyArray; @@ -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 { + 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, }; } diff --git a/ts/types/Polls.dom.ts b/ts/types/Polls.dom.ts index ce170d06f4..49f995cf80 100644 --- a/ts/types/Polls.dom.ts +++ b/ts/types/Polls.dom.ts @@ -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; @@ -98,6 +105,7 @@ export type PollMessageAttribute = { allowMultiple: boolean; votes?: ReadonlyArray; terminatedAt?: number; + terminateSendStatus?: PollTerminateSendStatus; }; export type PollCreateType = Pick< diff --git a/ts/types/Toast.dom.tsx b/ts/types/Toast.dom.tsx index 3520b7aee6..4cc4d3c960 100644 --- a/ts/types/Toast.dom.tsx +++ b/ts/types/Toast.dom.tsx @@ -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; diff --git a/ts/util/getNotificationDataForMessage.preload.ts b/ts/util/getNotificationDataForMessage.preload.ts index 6b181cab73..ff1d318338 100644 --- a/ts/util/getNotificationDataForMessage.preload.ts +++ b/ts/util/getNotificationDataForMessage.preload.ts @@ -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, diff --git a/ts/util/handleMessageSend.preload.ts b/ts/util/handleMessageSend.preload.ts index e5d383f97d..2f116b4d2b 100644 --- a/ts/util/handleMessageSend.preload.ts +++ b/ts/util/handleMessageSend.preload.ts @@ -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 diff --git a/ts/util/wrapWithSyncMessageSend.preload.ts b/ts/util/wrapWithSyncMessageSend.preload.ts index aae27b9a3b..8672829ebd 100644 --- a/ts/util/wrapWithSyncMessageSend.preload.ts +++ b/ts/util/wrapWithSyncMessageSend.preload.ts @@ -44,6 +44,7 @@ export async function wrapWithSyncMessageSend({ messageIds, sendType, }); + didSuccessfullySendOne = true; } catch (thrown) { if (thrown instanceof SendMessageProtoError) {