+ {getPaymentEventDescription( + payment, + author.title, + conversationTitle, + author.isMe, + i18n + )} +
++ {i18n('icu:payment-event-notification-check-primary-device')} +
+ {payment.note != null && ( +
+
{ {this.renderStoryReplyContext()} {this.renderAttachment()} {this.renderPreview()} + {this.renderPayment()} {this.renderEmbeddedContact()} {this.renderText()} {this.renderMetadata()} diff --git a/ts/components/conversation/PaymentEventNotification.tsx b/ts/components/conversation/PaymentEventNotification.tsx new file mode 100644 index 000000000000..41e49f4ff7a9 --- /dev/null +++ b/ts/components/conversation/PaymentEventNotification.tsx @@ -0,0 +1,32 @@ +// Copyright 2020-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import type { LocalizerType } from '../../types/Util'; +import type { ConversationType } from '../../state/ducks/conversations'; +import { SystemMessage } from './SystemMessage'; +import { Emojify } from './Emojify'; +import type { AnyPaymentEvent } from '../../types/Payment'; +import { getPaymentEventDescription } from '../../messages/helpers'; + +export type PropsType = { + event: AnyPaymentEvent; + sender: ConversationType; + conversation: ConversationType; + i18n: LocalizerType; +}; + +export function PaymentEventNotification(props: PropsType): JSX.Element { + const { event, sender, conversation, i18n } = props; + const message = getPaymentEventDescription( + event, + sender.title, + conversation.title, + sender.isMe, + i18n + ); + return ( +} /> + ); +} diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index e6d3c35fcbeb..d9100516a210 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -26,6 +26,7 @@ import enMessages from '../../../_locales/en/messages.json'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { WidthBreakpoint } from '../_util'; import { ThemeType } from '../../types/Util'; +import { PaymentEventKind } from '../../types/Payment'; const i18n = setupI18n('en', enMessages); @@ -166,6 +167,7 @@ const renderInMessage = ({ authorId: 'an-author', authorTitle, conversationColor, + conversationTitle: getDefaultConversation().title, isFromMe, rawAttachment, isViewOnce, @@ -567,3 +569,12 @@ IsStoryReplyEmoji.args = { IsStoryReplyEmoji.story = { name: 'isStoryReply emoji', }; + +export const Payment = Template.bind({}); +Payment.args = { + text: '', + payment: { + kind: PaymentEventKind.Notification, + note: null, + }, +}; diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index d169b27347aa..34264ca5a95d 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -22,10 +22,14 @@ import { TextAttachment } from '../TextAttachment'; import { getTextWithMentions } from '../../util/getTextWithMentions'; import { getClassNamesFor } from '../../util/getClassNamesFor'; import { getCustomColorStyle } from '../../util/getCustomColorStyle'; +import type { AnyPaymentEvent } from '../../types/Payment'; +import { PaymentEventKind } from '../../types/Payment'; +import { getPaymentEventNotificationText } from '../../messages/helpers'; export type Props = { authorTitle: string; conversationColor: ConversationColorType; + conversationTitle: string; customColor?: CustomColorType; bodyRanges?: HydratedBodyRangesType; i18n: LocalizerType; @@ -38,6 +42,7 @@ export type Props = { onClose?: () => void; text: string; rawAttachment?: QuotedAttachmentType; + payment?: AnyPaymentEvent; isGiftBadge: boolean; isViewOnce: boolean; reactionEmoji?: string; @@ -74,6 +79,10 @@ function validateQuote(quote: Props): boolean { return true; } + if (quote.payment?.kind === PaymentEventKind.Notification) { + return true; + } + return false; } @@ -274,6 +283,28 @@ export class Quote extends React.Component { ); } + public renderPayment(): JSX.Element | null { + const { payment, authorTitle, conversationTitle, isFromMe, i18n } = + this.props; + + if (payment == null) { + return null; + } + + return ( + <> + + {getPaymentEventNotificationText( + payment, + authorTitle, + conversationTitle, + isFromMe, + i18n + )} + > + ); + } + public renderIconContainer(): JSX.Element | null { const { isGiftBadge, isViewOnce, i18n, rawAttachment } = this.props; const { imageBroken } = this.state; @@ -550,6 +581,7 @@ export class Quote extends React.Component { {this.renderAuthor()} {this.renderGenericFile()} + {this.renderPayment()} {this.renderText()}{reactionEmoji && ( diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index e692676907d3..470fdf5ec011 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -26,6 +26,8 @@ import { ReadStatus } from '../../messages/MessageReadStatus'; import type { WidthBreakpoint } from '../_util'; import { ThemeType } from '../../types/Util'; import { TextDirection } from './Message'; +import { PaymentEventKind } from '../../types/Payment'; +import type { PropsData as TimelineMessageProps } from './TimelineMessage'; const i18n = setupI18n('en', enMessages); @@ -36,61 +38,53 @@ export default { // eslint-disable-next-line const noop = () => {}; -const items: Record= { - 'id-1': { - type: 'message', - data: { - author: getDefaultConversation({ - phoneNumber: '(202) 555-2001', - }), - canDeleteForEveryone: false, - canDownload: true, - canReact: true, - canReply: true, - canRetry: true, - canRetryDeleteForEveryone: true, - conversationColor: 'forest', - conversationId: 'conversation-id', - conversationTitle: 'Conversation Title', - conversationType: 'group', - direction: 'incoming', - id: 'id-1', - isBlocked: false, - isMessageRequestAccepted: true, - previews: [], - readStatus: ReadStatus.Read, - text: 'π₯', - textDirection: TextDirection.Default, - timestamp: Date.now(), - }, - timestamp: Date.now(), - }, - 'id-2': { +function mockMessageTimelineItem( + id: string, + data: Partial +): TimelineItemType { + return { type: 'message', data: { + id, author: getDefaultConversation({}), canDeleteForEveryone: false, canDownload: true, canReact: true, canReply: true, canRetry: true, - canRetryDeleteForEveryone: true, - conversationColor: 'forest', conversationId: 'conversation-id', conversationTitle: 'Conversation Title', conversationType: 'group', + conversationColor: 'crimson', direction: 'incoming', - id: 'id-2', + status: 'sent', + text: 'Hello there from the new world!', isBlocked: false, isMessageRequestAccepted: true, previews: [], readStatus: ReadStatus.Read, - text: 'Hello there from the new world! http://somewhere.com', + canRetryDeleteForEveryone: true, textDirection: TextDirection.Default, timestamp: Date.now(), + ...data, }, timestamp: Date.now(), - }, + }; +} + +const items: Record = { + 'id-1': mockMessageTimelineItem('id-1', { + author: getDefaultConversation({ + phoneNumber: '(202) 555-2001', + }), + conversationColor: 'forest', + text: 'π₯', + }), + 'id-2': mockMessageTimelineItem('id-2', { + conversationColor: 'forest', + direction: 'incoming', + text: 'Hello there from the new world! http://somewhere.com', + }), 'id-2.5': { type: 'unsupportedMessage', data: { @@ -105,32 +99,7 @@ const items: Record = { }, timestamp: Date.now(), }, - 'id-3': { - type: 'message', - data: { - author: getDefaultConversation({}), - canDeleteForEveryone: false, - canDownload: true, - canReact: true, - canReply: true, - canRetry: true, - canRetryDeleteForEveryone: true, - conversationColor: 'crimson', - conversationId: 'conversation-id', - conversationTitle: 'Conversation Title', - conversationType: 'group', - direction: 'incoming', - id: 'id-3', - isBlocked: false, - isMessageRequestAccepted: true, - previews: [], - readStatus: ReadStatus.Read, - text: 'Hello there from the new world!', - textDirection: TextDirection.Default, - timestamp: Date.now(), - }, - timestamp: Date.now(), - }, + 'id-3': mockMessageTimelineItem('id-3', {}), 'id-4': { type: 'timerNotification', data: { @@ -206,141 +175,85 @@ const items: Record = { data: null, timestamp: Date.now(), }, - 'id-10': { - type: 'message', + 'id-10': mockMessageTimelineItem('id-10', { + conversationColor: 'plum', + direction: 'outgoing', + text: 'π₯', + }), + 'id-11': mockMessageTimelineItem('id-11', { + direction: 'outgoing', + status: 'read', + text: 'Hello there from the new world! http://somewhere.com', + }), + 'id-12': mockMessageTimelineItem('id-12', { + direction: 'outgoing', + text: 'Hello there from the new world! π₯', + }), + 'id-13': mockMessageTimelineItem('id-13', { + direction: 'outgoing', + text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.', + }), + 'id-14': mockMessageTimelineItem('id-14', { + direction: 'outgoing', + status: 'read', + text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.', + }), + 'id-15': { + type: 'paymentEvent', data: { - author: getDefaultConversation({}), - canDeleteForEveryone: false, - canDownload: true, - canReact: true, - canReply: true, - canRetry: true, - canRetryDeleteForEveryone: true, - conversationColor: 'plum', - conversationId: 'conversation-id', - conversationTitle: 'Conversation Title', - conversationType: 'group', - direction: 'outgoing', - id: 'id-6', - isBlocked: false, - isMessageRequestAccepted: true, - previews: [], - readStatus: ReadStatus.Read, - status: 'sent', - text: 'π₯', - textDirection: TextDirection.Default, - timestamp: Date.now(), + event: { + kind: PaymentEventKind.ActivationRequest, + }, + sender: getDefaultConversation(), + conversation: getDefaultConversation(), }, timestamp: Date.now(), }, - 'id-11': { - type: 'message', + 'id-16': { + type: 'paymentEvent', data: { - author: getDefaultConversation({}), - canDeleteForEveryone: false, - canDownload: true, - canReact: true, - canReply: true, - canRetry: true, - canRetryDeleteForEveryone: true, - conversationColor: 'crimson', - conversationId: 'conversation-id', - conversationTitle: 'Conversation Title', - conversationType: 'group', - direction: 'outgoing', - id: 'id-7', - isBlocked: false, - isMessageRequestAccepted: true, - previews: [], - readStatus: ReadStatus.Read, - status: 'read', - text: 'Hello there from the new world! http://somewhere.com', - textDirection: TextDirection.Default, - timestamp: Date.now(), + event: { + kind: PaymentEventKind.Activation, + }, + sender: getDefaultConversation(), + conversation: getDefaultConversation(), }, timestamp: Date.now(), }, - 'id-12': { - type: 'message', + 'id-17': { + type: 'paymentEvent', data: { - author: getDefaultConversation({}), - canDeleteForEveryone: false, - canDownload: true, - canReact: true, - canReply: true, - canRetry: true, - canRetryDeleteForEveryone: true, - conversationColor: 'crimson', - conversationId: 'conversation-id', - conversationTitle: 'Conversation Title', - conversationType: 'group', - direction: 'outgoing', - id: 'id-8', - isBlocked: false, - isMessageRequestAccepted: true, - previews: [], - readStatus: ReadStatus.Read, - status: 'sent', - text: 'Hello there from the new world! π₯', - textDirection: TextDirection.Default, - timestamp: Date.now(), + event: { + kind: PaymentEventKind.ActivationRequest, + }, + sender: getDefaultConversation({ + isMe: true, + }), + conversation: getDefaultConversation(), }, timestamp: Date.now(), }, - 'id-13': { - type: 'message', + 'id-18': { + type: 'paymentEvent', data: { - author: getDefaultConversation({}), - canDeleteForEveryone: false, - canDownload: true, - canReact: true, - canReply: true, - canRetry: true, - canRetryDeleteForEveryone: true, - conversationColor: 'crimson', - conversationId: 'conversation-id', - conversationTitle: 'Conversation Title', - conversationType: 'group', - direction: 'outgoing', - id: 'id-9', - isBlocked: false, - isMessageRequestAccepted: true, - previews: [], - readStatus: ReadStatus.Read, - status: 'sent', - text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.', - textDirection: TextDirection.Default, - timestamp: Date.now(), + event: { + kind: PaymentEventKind.Activation, + }, + sender: getDefaultConversation({ + isMe: true, + }), + conversation: getDefaultConversation(), }, timestamp: Date.now(), }, - 'id-14': { - type: 'message', - data: { - author: getDefaultConversation({}), - canDeleteForEveryone: false, - canDownload: true, - canReact: true, - canReply: true, - canRetry: true, - canRetryDeleteForEveryone: true, - conversationColor: 'crimson', - conversationId: 'conversation-id', - conversationTitle: 'Conversation Title', - conversationType: 'group', - direction: 'outgoing', - id: 'id-10', - isBlocked: false, - isMessageRequestAccepted: true, - previews: [], - readStatus: ReadStatus.Read, - status: 'read', - text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.', - textDirection: TextDirection.Default, - timestamp: Date.now(), + 'id-19': mockMessageTimelineItem('id-19', { + direction: 'outgoing', + status: 'read', + payment: { + kind: PaymentEventKind.Notification, + note: 'Thanks', }, - timestamp: Date.now(), - }, + }), }; const actions = () => ({ diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index b43756e8b67a..77bc68c6c3bf 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -17,6 +17,7 @@ import { AvatarColors } from '../../types/Colors'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { WidthBreakpoint } from '../_util'; import { ThemeType } from '../../types/Util'; +import { PaymentEventKind } from '../../types/Payment'; const i18n = setupI18n('en', enMessages); @@ -437,6 +438,50 @@ export function Notification(): JSX.Element { changedContact: getDefaultConversation(), }, }, + { + type: 'paymentEvent', + data: { + event: { + kind: PaymentEventKind.ActivationRequest, + }, + sender: getDefaultConversation(), + conversation: getDefaultConversation(), + }, + }, + { + type: 'paymentEvent', + data: { + event: { + kind: PaymentEventKind.Activation, + }, + sender: getDefaultConversation(), + conversation: getDefaultConversation(), + }, + }, + { + type: 'paymentEvent', + data: { + event: { + kind: PaymentEventKind.ActivationRequest, + }, + sender: getDefaultConversation({ + isMe: true, + }), + conversation: getDefaultConversation(), + }, + }, + { + type: 'paymentEvent', + data: { + event: { + kind: PaymentEventKind.Activation, + }, + sender: getDefaultConversation({ + isMe: true, + }), + conversation: getDefaultConversation(), + }, + }, { type: 'resetSessionNotification', data: null, diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 7f9d97b6e9b2..60adb19fcce7 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -53,6 +53,8 @@ import type { SmartContactRendererType } from '../../groupChange'; import { ResetSessionNotification } from './ResetSessionNotification'; import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileChangeNotification'; import { ProfileChangeNotification } from './ProfileChangeNotification'; +import type { PropsType as PaymentEventNotificationPropsType } from './PaymentEventNotification'; +import { PaymentEventNotification } from './PaymentEventNotification'; import type { FullJSXType } from '../Intl'; import { TimelineMessage } from './TimelineMessage'; @@ -116,6 +118,10 @@ type ProfileChangeNotificationType = { type: 'profileChange'; data: ProfileChangeNotificationPropsType; }; +type PaymentEventType = { + type: 'paymentEvent'; + data: Omit ; +}; export type TimelineItemType = ( | CallHistoryType @@ -133,6 +139,7 @@ export type TimelineItemType = ( | ChangeNumberNotificationType | UnsupportedMessageType | VerificationNotificationType + | PaymentEventType ) & { timestamp: number }; type PropsLocalType = { @@ -302,6 +309,14 @@ export class TimelineItem extends React.PureComponent { i18n={i18n} /> ); + } else if (item.type === 'paymentEvent') { + notification = ( + + ); } else { // Weird, yes, but the idea is to get a compile error when we aren't comprehensive // with our if/else checks above, but also log out the type we don't understand diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index 6a225f2a1cf4..2f7bad69ea05 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -44,6 +44,7 @@ import { getFakeBadge } from '../../test-both/helpers/getFakeBadge'; import { ThemeType } from '../../types/Util'; import { UUID } from '../../types/UUID'; import { BadgeCategory } from '../../badges/BadgeCategory'; +import { PaymentEventKind } from '../../types/Payment'; const i18n = setupI18n('en', enMessages); @@ -863,6 +864,7 @@ export const LinkPreviewWithQuote = Template.bind({}); LinkPreviewWithQuote.args = { quote: { conversationColor: ConversationColors[2], + conversationTitle: getDefaultConversation().title, text: 'The quoted message', isFromMe: false, sentAt: Date.now(), @@ -2069,3 +2071,13 @@ GiftBadgeMissingBadge.args = { GiftBadgeMissingBadge.story = { name: 'Gift Badge: Missing Badge', }; + +export const PaymentNotification = Template.bind({}); +PaymentNotification.args = { + canReply: false, + canReact: false, + payment: { + kind: PaymentEventKind.Notification, + note: 'Hello there', + }, +}; diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx index ab4b493189bc..5ec41ec80100 100644 --- a/ts/components/conversation/TimelineMessage.tsx +++ b/ts/components/conversation/TimelineMessage.tsx @@ -78,6 +78,7 @@ export function TimelineMessage(props: Props): JSX.Element { canDeleteForEveryone, canRetryDeleteForEveryone, contact, + payment, containerElementRef, containerWidthBreakpoint, deletedForEveryone, @@ -210,7 +211,7 @@ export function TimelineMessage(props: Props): JSX.Element { }; const canForward = - !isTapToView && !deletedForEveryone && !giftBadge && !contact; + !isTapToView && !deletedForEveryone && !giftBadge && !contact && !payment; const shouldShowAdditional = doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow; diff --git a/ts/messages/helpers.ts b/ts/messages/helpers.ts index b76aee555476..2ec3e404b5e0 100644 --- a/ts/messages/helpers.ts +++ b/ts/messages/helpers.ts @@ -9,6 +9,10 @@ import type { QuotedMessageType, } from '../model-types.d'; import type { UUIDStringType } from '../types/UUID'; +import { PaymentEventKind } from '../types/Payment'; +import type { AnyPaymentEvent } from '../types/Payment'; +import type { LocalizerType } from '../types/Util'; +import { missingCaseError } from '../util/missingCaseError'; export function isIncoming( message: Pick @@ -26,6 +30,84 @@ export function isStory(message: Pick ): boolean { return message.type === 'story'; } +export type MessageAttributesWithPaymentEvent = MessageAttributesType & { + payment: AnyPaymentEvent; +}; + +export function messageHasPaymentEvent( + message: MessageAttributesType +): message is MessageAttributesWithPaymentEvent { + return message.payment != null; +} + +export function getPaymentEventNotificationText( + payment: AnyPaymentEvent, + senderTitle: string, + conversationTitle: string | null, + senderIsMe: boolean, + i18n: LocalizerType +): string { + if (payment.kind === PaymentEventKind.Notification) { + return i18n('icu:payment-event-notification-label'); + } + return getPaymentEventDescription( + payment, + senderTitle, + conversationTitle, + senderIsMe, + i18n + ); +} + +export function getPaymentEventDescription( + payment: AnyPaymentEvent, + senderTitle: string, + conversationTitle: string | null, + senderIsMe: boolean, + i18n: LocalizerType +): string { + const { kind } = payment; + if (kind === PaymentEventKind.Notification) { + if (senderIsMe) { + if (conversationTitle != null) { + return i18n('icu:payment-event-notification-message-you-label', { + receiver: conversationTitle, + }); + } + return i18n( + 'icu:payment-event-notification-message-you-label-without-receiver' + ); + } + return i18n('icu:payment-event-notification-message-label', { + sender: senderTitle, + }); + } + if (kind === PaymentEventKind.ActivationRequest) { + if (senderIsMe) { + if (conversationTitle != null) { + return i18n('icu:payment-event-activation-request-you-label', { + receiver: conversationTitle, + }); + } + return i18n( + 'icu:payment-event-activation-request-you-label-without-receiver' + ); + } + return i18n('icu:payment-event-activation-request-label', { + sender: senderTitle, + }); + } + if (kind === PaymentEventKind.Activation) { + if (senderIsMe) { + return i18n('icu:payment-event-activated-you-label'); + } + return i18n('icu:payment-event-activated-label', { + sender: senderTitle, + }); + } + throw missingCaseError(kind); +} + export function isQuoteAMatch( message: MessageAttributesType | null | undefined, conversationId: string, diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index db6eca6e27c2..08ff21d9a912 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -33,6 +33,7 @@ import type { StickerType } from './types/Stickers'; import type { StorySendMode } from './types/Stories'; import type { MIMEType } from './types/MIME'; import type { DurationInSeconds } from './util/durations'; +import type { AnyPaymentEvent } from './types/Payment'; import AccessRequiredEnum = Proto.AccessControl.AccessRequired; import MemberRoleEnum = Proto.Member.Role; @@ -76,6 +77,7 @@ export type QuotedMessageType = { // TODO DESKTOP-3826 // eslint-disable-next-line @typescript-eslint/no-explicit-any attachments: Array ; + payment?: AnyPaymentEvent; // `author` is an old attribute that holds the author's E164. We shouldn't use it for // new messages, but old messages might have this attribute. author?: string; @@ -145,6 +147,7 @@ export type MessageAttributesType = { message?: unknown; messageTimer?: unknown; profileChange?: ProfileNameChangeType; + payment?: AnyPaymentEvent; quote?: QuotedMessageType; reactions?: ReadonlyArray ; requiredProtocolVersion?: number; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index e80401e8076d..acbcc9e79458 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -3787,6 +3787,7 @@ export class ConversationModel extends window.Backbone attachments: isTapToView(quotedMessage.attributes) ? [{ contentType: IMAGE_JPEG, fileName: null }] : await this.getQuoteAttachment(attachments, preview, sticker), + payment: quotedMessage.get('payment'), bodyRanges: quotedMessage.get('bodyRanges'), id: quotedMessage.get('sent_at'), isViewOnce: isTapToView(quotedMessage.attributes), diff --git a/ts/models/messages.ts b/ts/models/messages.ts index fa850b35cc94..e80aa8746ec0 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -159,7 +159,9 @@ import { getSource, getSourceUuid, isCustomError, + messageHasPaymentEvent, isQuoteAMatch, + getPaymentEventNotificationText, } from '../messages/helpers'; import type { ReplacementValuesType } from '../types/I18N'; import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue'; @@ -696,6 +698,21 @@ export class MessageModel extends window.Backbone.Model { return { text: changes.map(({ text }) => text).join(' ') }; } + if (messageHasPaymentEvent(attributes)) { + const sender = findAndFormatContact(attributes.sourceUuid); + const conversation = findAndFormatContact(attributes.conversationId); + return { + text: getPaymentEventNotificationText( + attributes.payment, + sender.title, + conversation.title, + sender.isMe, + window.i18n + ), + emoji: 'π³', + }; + } + const attachments = this.get('attachments') || []; if (isTapToView(attributes)) { @@ -1306,6 +1323,8 @@ export class MessageModel extends window.Backbone.Model { const isUniversalTimerNotificationValue = isUniversalTimerNotification(attributes); + const isPayment = messageHasPaymentEvent(attributes); + // Note: not all of these message types go through message.handleDataMessage const hasSomethingToDisplay = @@ -1314,6 +1333,7 @@ export class MessageModel extends window.Backbone.Model { hasAttachment || hasEmbeddedContact || isSticker || + isPayment || // Rendered sync messages isCallHistoryValue || isChatSessionRefreshedValue || @@ -2094,6 +2114,11 @@ export class MessageModel extends window.Backbone.Model { const { attachments } = quote; const firstAttachment = attachments ? attachments[0] : undefined; + if (messageHasPaymentEvent(originalMessage.attributes)) { + // eslint-disable-next-line no-param-reassign + quote.payment = originalMessage.get('payment'); + } + if (isTapToView(originalMessage.attributes)) { // eslint-disable-next-line no-param-reassign quote.text = undefined; @@ -2670,6 +2695,7 @@ export class MessageModel extends window.Backbone.Model { dataMessage.requiredProtocolVersion || this.INITIAL_PROTOCOL_VERSION, supportedVersionAtReceive: this.CURRENT_PROTOCOL_VERSION, + payment: dataMessage.payment, quote: dataMessage.quote, schemaVersion: dataMessage.schemaVersion, sticker: dataMessage.sticker, @@ -2962,7 +2988,8 @@ export class MessageModel extends window.Backbone.Model { !isStory(message.attributes) && !isGroupStoryReply && (!conversationTimestamp || - message.get('sent_at') > conversationTimestamp) + message.get('sent_at') > conversationTimestamp) && + messageHasPaymentEvent(message.attributes) ) { conversation.set({ lastMessage: message.getNotificationText(), diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index 0552b653d060..517e3bb455be 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -273,7 +273,7 @@ function setMediaQualitySetting( } function setQuotedMessage( - payload?: Pick + payload?: Pick ): SetQuotedMessageActionType { return { type: SET_QUOTED_MESSAGE, diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 800319296468..be8047a86f21 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -25,6 +25,7 @@ import type { PropsData as VerificationNotificationProps } from '../../component import type { PropsDataType as GroupsV2Props } from '../../components/conversation/GroupV2Change'; import type { PropsDataType as GroupV1MigrationPropsType } from '../../components/conversation/GroupV1Migration'; import type { PropsDataType as DeliveryIssuePropsType } from '../../components/conversation/DeliveryIssueNotification'; +import type { PropsType as PaymentEventNotificationPropsType } from '../../components/conversation/PaymentEventNotification'; import type { PropsData as GroupNotificationProps, ChangeType, @@ -95,9 +96,18 @@ import * as log from '../../logging/log'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; import { DAY, HOUR, DurationInSeconds } from '../../util/durations'; import { getStoryReplyText } from '../../util/getStoryReplyText'; -import { isIncoming, isOutgoing, isStory } from '../../messages/helpers'; +import type { MessageAttributesWithPaymentEvent } from '../../messages/helpers'; +import { + isIncoming, + isOutgoing, + messageHasPaymentEvent, + isStory, +} from '../../messages/helpers'; + import { calculateExpirationTimestamp } from '../../util/expirationTimer'; import { isSignalConversation } from '../../util/isSignalConversation'; +import type { AnyPaymentEvent } from '../../types/Payment'; +import { isPaymentNotificationEvent } from '../../types/Payment'; export { isIncoming, isOutgoing, isStory }; @@ -494,7 +504,10 @@ export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)( identity, ( - message: Pick , + message: Pick< + MessageWithUIFieldsType, + 'conversationId' | 'quote' | 'payment' + >, { conversationSelector, ourConversationId, @@ -515,6 +528,7 @@ export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)( isViewOnce, isGiftBadge: isTargetGiftBadge, referencedMessageNotFound, + payment, text = '', } = quote; @@ -541,11 +555,13 @@ export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)( authorTitle, bodyRanges: processBodyRanges(quote, { conversationSelector }), conversationColor, + conversationTitle: conversation.title, customColor, isFromMe, rawAttachment: firstAttachment ? processQuoteAttachment(firstAttachment) : undefined, + payment, isGiftBadge: Boolean(isTargetGiftBadge), isViewOnce, referencedMessageNotFound, @@ -709,6 +725,12 @@ function getTextAttachment( ); } +function getPayment( + message: MessageWithUIFieldsType +): AnyPaymentEvent | undefined { + return message.payment; +} + export function cleanBodyForDirectionCheck(text: string): string { const MENTIONS_REGEX = getMentionsRegex(); const EMOJI_REGEX = emojiRegex(); @@ -778,6 +800,7 @@ export const getPropsForMessage: ( getPropsForQuote, getPropsForStoryReplyContext, getTextAttachment, + getPayment, getShallowPropsForMessage, ( _, @@ -789,6 +812,7 @@ export const getPropsForMessage: ( quote: PropsData['quote'], storyReplyContext: PropsData['storyReplyContext'], textAttachment: PropsData['textAttachment'], + payment: PropsData['payment'], shallowProps: ShallowPropsType ): Omit => { return { @@ -800,6 +824,7 @@ export const getPropsForMessage: ( reactions, storyReplyContext, textAttachment, + payment, ...shallowProps, }; } @@ -966,9 +991,31 @@ export function getPropsForBubble( }; } + if ( + messageHasPaymentEvent(message) && + !isPaymentNotificationEvent(message.payment) + ) { + return { + type: 'paymentEvent', + data: getPropsForPaymentEvent(message, options), + timestamp, + }; + } + return getBubblePropsForMessage(message, options); } +function getPropsForPaymentEvent( + message: MessageAttributesWithPaymentEvent, + { conversationSelector }: GetPropsForBubbleOptions +): Omit { + return { + sender: conversationSelector(message.sourceUuid), + conversation: conversationSelector(message.conversationId), + event: message.payment, + }; +} + // Unsupported Message export function isUnsupportedMessage( @@ -1640,6 +1687,7 @@ function canReplyOrReact( | 'canReplyToStory' | 'deletedForEveryone' | 'sendStateByConversationId' + | 'payment' | 'type' >, ourConversationId: string | undefined, diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 1d489bbafa3b..d451c53083a4 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -8,6 +8,7 @@ import type { TextAttachmentType } from '../types/Attachment'; import type { GiftBadgeStates } from '../components/conversation/Message'; import type { MIMEType } from '../types/MIME'; import type { DurationInSeconds } from '../util/durations'; +import type { AnyPaymentEvent } from '../types/Payment'; export { IdentityKeyType, @@ -211,6 +212,7 @@ export type ProcessedDataMessage = { expireTimer: DurationInSeconds; profileKey?: string; timestamp: number; + payment?: AnyPaymentEvent; quote?: ProcessedQuote; contact?: ReadonlyArray ; preview?: ReadonlyArray ; diff --git a/ts/textsecure/processDataMessage.ts b/ts/textsecure/processDataMessage.ts index 0ea1e3847fe2..a06189b2f404 100644 --- a/ts/textsecure/processDataMessage.ts +++ b/ts/textsecure/processDataMessage.ts @@ -29,6 +29,8 @@ import { WarnOnlyError } from './Errors'; import { GiftBadgeStates } from '../components/conversation/Message'; import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../types/MIME'; import { SECOND, DurationInSeconds } from '../util/durations'; +import type { AnyPaymentEvent } from '../types/Payment'; +import { PaymentEventKind } from '../types/Payment'; const FLAGS = Proto.DataMessage.Flags; export const ATTACHMENT_MAX = 32; @@ -123,6 +125,38 @@ export function processGroupV2Context( }; } +export function processPayment( + payment?: Proto.DataMessage.IPayment | null +): AnyPaymentEvent | undefined { + if (!payment) { + return undefined; + } + + if (payment.notification != null) { + return { + kind: PaymentEventKind.Notification, + note: payment.notification.note ?? null, + }; + } + + if (payment.activation != null) { + if ( + payment.activation.type === + Proto.DataMessage.Payment.Activation.Type.REQUEST + ) { + return { kind: PaymentEventKind.ActivationRequest }; + } + if ( + payment.activation.type === + Proto.DataMessage.Payment.Activation.Type.ACTIVATED + ) { + return { kind: PaymentEventKind.Activation }; + } + } + + return undefined; +} + export function processQuote( quote?: Proto.DataMessage.IQuote | null ): ProcessedQuote | undefined { @@ -305,6 +339,7 @@ export function processDataMessage( ? Bytes.toBase64(message.profileKey) : undefined, timestamp, + payment: processPayment(message.payment), quote: processQuote(message.quote), contact: processContact(message.contact), preview: processPreview(message.preview), diff --git a/ts/types/Payment.ts b/ts/types/Payment.ts new file mode 100644 index 000000000000..371f237abf1e --- /dev/null +++ b/ts/types/Payment.ts @@ -0,0 +1,34 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export enum PaymentEventKind { + Notification = 1, + ActivationRequest = 2, + Activation = 3, + // Request = 4, -- disabled + // Cancellation = 5, -- disabled +} + +export type PaymentNotificationEvent = { + kind: PaymentEventKind.Notification; + note: string | null; +}; + +export type PaymentActivationRequestEvent = { + kind: PaymentEventKind.ActivationRequest; +}; + +export type PaymentActivatedEvent = { + kind: PaymentEventKind.Activation; +}; + +export type AnyPaymentEvent = + | PaymentNotificationEvent + | PaymentActivationRequestEvent + | PaymentActivatedEvent; + +export function isPaymentNotificationEvent( + event: AnyPaymentEvent +): event is PaymentNotificationEvent { + return event.kind === PaymentEventKind.Notification; +}