mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-05 01:10:49 +00:00
Add ability for poll author to terminate a poll
This commit is contained in:
@@ -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"
|
||||
|
||||
1
images/icons/v3/stop/stop-circle.svg
Normal file
1
images/icons/v3/stop/stop-circle.svg
Normal 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 |
@@ -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(
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
&--has-icon::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 16px;
|
||||
|
||||
@@ -52,6 +52,7 @@ const MESSAGE_DEFAULT_PROPS = {
|
||||
previews: [],
|
||||
retryMessageSend: shouldNeverBeCalled,
|
||||
sendPollVote: shouldNeverBeCalled,
|
||||
endPoll: shouldNeverBeCalled,
|
||||
pushPanelForConversation: shouldNeverBeCalled,
|
||||
renderAudioAttachment: () => <div />,
|
||||
renderingContext: 'EditHistoryMessagesModal',
|
||||
|
||||
@@ -66,6 +66,7 @@ const MESSAGE_DEFAULT_PROPS = {
|
||||
previews: [],
|
||||
retryMessageSend: shouldNeverBeCalled,
|
||||
sendPollVote: shouldNeverBeCalled,
|
||||
endPoll: shouldNeverBeCalled,
|
||||
pushPanelForConversation: shouldNeverBeCalled,
|
||||
renderAudioAttachment: () => <div />,
|
||||
saveAttachment: shouldNeverBeCalled,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}
|
||||
|
||||
53
ts/components/conversation/PollTerminateNotification.dom.tsx
Normal file
53
ts/components/conversation/PollTerminateNotification.dom.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,7 +14,16 @@ export enum SystemMessageKind {
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
export type PropsType = {
|
||||
type SystemMessageBaseProps = {
|
||||
contents: ReactNode;
|
||||
button?: ReactNode;
|
||||
kind?: SystemMessageKind;
|
||||
};
|
||||
|
||||
export type PropsType = SystemMessageBaseProps &
|
||||
(
|
||||
| {
|
||||
/** @deprecated Use symbol instead */
|
||||
icon:
|
||||
| 'audio-incoming'
|
||||
| 'audio-missed'
|
||||
@@ -47,14 +59,17 @@ export type PropsType = {
|
||||
| 'warning'
|
||||
| 'payment-event'
|
||||
| 'merge';
|
||||
contents: ReactNode;
|
||||
button?: ReactNode;
|
||||
kind?: SystemMessageKind;
|
||||
};
|
||||
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 && (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,15 +48,19 @@ 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')}>
|
||||
<React.Fragment key={optionKey}>
|
||||
<div className={tw('flex flex-col')}>
|
||||
{/* Option Header */}
|
||||
<div
|
||||
className={tw('mb-3 flex items-start gap-3 text-label-primary')}
|
||||
className={tw(
|
||||
'mb-3 flex items-start gap-3 text-label-primary'
|
||||
)}
|
||||
>
|
||||
<div className={tw('type-title-small')}>{option}</div>
|
||||
<div
|
||||
@@ -91,6 +102,10 @@ export function PollVotesModal({
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
206
ts/jobs/helpers/sendPollTerminate.preload.ts
Normal file
206
ts/jobs/helpers/sendPollTerminate.preload.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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') || {};
|
||||
|
||||
@@ -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
5
ts/model-types.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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> = {}
|
||||
|
||||
74
ts/polls/enqueuePollTerminateForSend.preload.ts
Normal file
74
ts/polls/enqueuePollTerminateForSend.preload.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export async function wrapWithSyncMessageSend({
|
||||
messageIds,
|
||||
sendType,
|
||||
});
|
||||
|
||||
didSuccessfullySendOne = true;
|
||||
} catch (thrown) {
|
||||
if (thrown instanceof SendMessageProtoError) {
|
||||
|
||||
Reference in New Issue
Block a user