From 6198b0264038245233feb8a2bed71d2792a2c813 Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Wed, 30 Nov 2022 13:47:54 -0800 Subject: [PATCH] Init payments message types --- _locales/en/messages.json | 40 +++ images/icons/v2/credit-card-16.svg | 5 + protos/SignalService.proto | 58 +++- stylesheets/_modules.scss | 66 +++++ stylesheets/components/SystemMessage.scss | 7 + ts/components/CompositionArea.stories.tsx | 30 ++ ts/components/StoryViewer.tsx | 1 + ts/components/StoryViewsNRepliesModal.tsx | 3 + ts/components/conversation/Message.tsx | 55 ++++ .../conversation/PaymentEventNotification.tsx | 32 +++ ts/components/conversation/Quote.stories.tsx | 11 + ts/components/conversation/Quote.tsx | 32 +++ .../conversation/Timeline.stories.tsx | 271 ++++++------------ .../conversation/TimelineItem.stories.tsx | 45 +++ ts/components/conversation/TimelineItem.tsx | 15 + .../conversation/TimelineMessage.stories.tsx | 12 + .../conversation/TimelineMessage.tsx | 3 +- ts/messages/helpers.ts | 82 ++++++ ts/model-types.d.ts | 3 + ts/models/conversations.ts | 1 + ts/models/messages.ts | 29 +- ts/state/ducks/composer.ts | 2 +- ts/state/selectors/message.ts | 52 +++- ts/textsecure/Types.d.ts | 2 + ts/textsecure/processDataMessage.ts | 35 +++ ts/types/Payment.ts | 34 +++ 26 files changed, 741 insertions(+), 185 deletions(-) create mode 100644 images/icons/v2/credit-card-16.svg create mode 100644 ts/components/conversation/PaymentEventNotification.tsx create mode 100644 ts/types/Payment.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 55dc791f9850..4f90a4b5710e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5483,6 +5483,46 @@ "message": "Delete this story? It will also be deleted for everyone who received it.", "description": "Confirmation dialog description text for deleting a story" }, + "icu:payment-event-notification-message-you-label": { + "messageformat": "You started a payment to {receiver}", + "description": "Payment event notification from you message bubble label" + }, + "icu:payment-event-notification-message-you-label-without-receiver": { + "messageformat": "You started a payment", + "description": "Payment event notification from you message bubble label" + }, + "icu:payment-event-notification-message-label": { + "messageformat": "{sender} started a payment to you", + "description": "Payment event notification from contact message bubble label" + }, + "icu:payment-event-activation-request-label": { + "messageformat": "{sender} wants you to activate Payments. Only send payments to people you trust. Payments can be activated on your mobile device by going to Settings -> Payments.", + "description": "Payment event activation request from contact label" + }, + "icu:payment-event-activation-request-you-label": { + "messageformat": "You sent {receiver} a request to activate Payments.", + "description": "Payment event activation request from you label" + }, + "icu:payment-event-activation-request-you-label-without-receiver": { + "messageformat": "You sent a request to activate Payments.", + "description": "Payment event activation request from you label" + }, + "icu:payment-event-activated-label": { + "messageformat": "{sender} can now accept Payments.", + "description": "Payment event activation from contact label" + }, + "icu:payment-event-activated-you-label": { + "messageformat": "You activated Payments.", + "description": "Payment event activation from you label" + }, + "icu:payment-event-notification-label": { + "messageformat": "Payment", + "description": "Payment event notification label" + }, + "icu:payment-event-notification-check-primary-device": { + "messageformat": "Check your primary device for this payment’s status", + "description": "Payment event notification check device label" + }, "SignalConnectionsModal__title": { "message": "Signal Connections", "description": "The phrase/term: 'Signal Connections'" diff --git a/images/icons/v2/credit-card-16.svg b/images/icons/v2/credit-card-16.svg new file mode 100644 index 000000000000..7799e206d425 --- /dev/null +++ b/images/icons/v2/credit-card-16.svg @@ -0,0 +1,5 @@ + + + diff --git a/protos/SignalService.proto b/protos/SignalService.proto index b7b9f4bbbc49..07cce1fb67ff 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -132,6 +132,62 @@ message DataMessage { PROFILE_KEY_UPDATE = 4; } + message Payment { + message Amount { + message MobileCoin { + optional uint64 picoMob = 1; // 1,000,000,000,000 picoMob per Mob + } + + oneof Amount { + MobileCoin mobileCoin = 1; + } + } + + message RequestId { + optional string uuid = 1; + } + + message Request { + optional RequestId requestId = 1; + optional Amount amount = 2; + optional string note = 3; + } + + message Notification { + message MobileCoin { + optional bytes receipt = 1; + } + + oneof Transaction { + MobileCoin mobileCoin = 1; + } + + // Optional, Refers to the PaymentRequest message, if any. + optional string note = 2; + optional RequestId requestId = 1003; + } + + message Cancellation { + optional RequestId requestId = 1; + } + + message Activation { + enum Type { + REQUEST = 0; + ACTIVATED = 1; + } + + optional Type type = 1; + } + + oneof Item { + Notification notification = 1; + Activation activation = 2; + Request request = 1002; + Cancellation cancellation = 1003; + } + } + message Quote { enum Type { NORMAL = 0; @@ -302,7 +358,7 @@ message DataMessage { optional Delete delete = 17; repeated BodyRange bodyRanges = 18; optional GroupCallUpdate groupCallUpdate = 19; - reserved /* Payment payment */ 20; + optional Payment payment = 20; optional StoryContext storyContext = 21; optional GiftBadge giftBadge = 22; } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index a7cf675221ef..5822fe708b8d 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3359,6 +3359,72 @@ button.module-image__border-overlay:focus { } } +.module-payment-notification { + &__container { + display: block; + } + + &__label { + margin: 0 0 7px; + @include font-subtitle; + + @include light-theme() { + color: $color-gray-60; + } + @include dark-theme() { + color: $color-gray-25; + } + } + + &__check_device_box { + display: flex; + gap: 9px; + align-items: center; + @include font-body-2; + padding: 22px 7px; + padding-left: 12px; + border-radius: 18px; + margin: 0 -4px; + + @include light-theme() { + background: $color-white-alpha-60; + color: $color-gray-90; + } + @include dark-theme() { + background: $color-white-alpha-20; + color: $color-white; + } + + &::before { + content: ''; + display: block; + flex-shrink: 0; + width: 16px; + height: 16px; + @include color-svg('../images/icons/v2/info-16.svg', currentcolor); + } + } + + &__note { + margin: 9px 0 0; + @include font-body-1; + } + + &--outgoing &__label { + @include light-theme() { + color: $color-white-alpha-80; + } + @include dark-theme() { + color: $color-white-alpha-80; + } + } + + &--outgoing &__check_device_box { + background: $color-white-alpha-20; + color: $color-white; + } +} + // Module: Spinner .module-spinner__container { diff --git a/stylesheets/components/SystemMessage.scss b/stylesheets/components/SystemMessage.scss index 231a8a273577..6706d5f8ea85 100644 --- a/stylesheets/components/SystemMessage.scss +++ b/stylesheets/components/SystemMessage.scss @@ -267,6 +267,13 @@ '../images/icons/v2/error-outline-12.svg' ); } + + &--icon-payment-event::before { + @include system-message-icon( + '../images/icons/v2/credit-card-16.svg', + '../images/icons/v2/credit-card-16.svg' + ); + } } &--error { diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index d153db9ed5c7..ad3a4264566c 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -18,6 +18,8 @@ import { fakeDraftAttachment } from '../test-both/helpers/fakeAttachment'; import { landscapeGreenUrl } from '../storybook/Fixtures'; import { RecordingState } from '../state/ducks/audioRecorder'; import { ConversationColors } from '../types/Colors'; +import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { PaymentEventKind } from '../types/Payment'; const i18n = setupI18n('en', enMessages); @@ -211,6 +213,7 @@ export function Quote(): JSX.Element { quotedMessageProps: { text: 'something', conversationColor: ConversationColors[10], + conversationTitle: getDefaultConversation().title, isGiftBadge: false, isViewOnce: false, referencedMessageNotFound: false, @@ -221,3 +224,30 @@ export function Quote(): JSX.Element { /> ); } + +export function QuoteWithPayment(): JSX.Element { + return ( + + ); +} + +QuoteWithPayment.story = { + name: 'Quote with payment', +}; diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 6ccbc6f9faa3..c451a455ba67 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -906,6 +906,7 @@ export function StoryViewer({ {(currentViewTarget === StoryViewTargetType.Replies || currentViewTarget === StoryViewTargetType.Views) && ( ; giftBadge?: GiftBadgeType; + payment?: AnyPaymentEvent; quote?: { conversationColor: ConversationColorType; + conversationTitle: string; customColor?: CustomColorType; text: string; rawAttachment?: QuotedAttachmentType; + payment?: AnyPaymentEvent; isFromMe: boolean; sentAt: number; authorId: string; @@ -1405,9 +1412,52 @@ export class Message extends React.PureComponent { throw missingCaseError(giftBadge.state); } + public renderPayment(): JSX.Element | null { + const { + payment, + direction, + author, + conversationTitle, + conversationColor, + i18n, + } = this.props; + if (payment == null || payment.kind !== PaymentEventKind.Notification) { + return null; + } + + return ( +
+

+ {getPaymentEventDescription( + payment, + author.title, + conversationTitle, + author.isMe, + i18n + )} +

+

+ {i18n('icu:payment-event-notification-check-primary-device')} +

+ {payment.note != null && ( +

+ +

+ )} +
+ ); + } + public renderQuote(): JSX.Element | null { const { conversationColor, + conversationTitle, customColor, direction, disableScroll, @@ -1441,10 +1491,12 @@ export class Message extends React.PureComponent { onClick={clickHandler} text={quote.text} rawAttachment={quote.rawAttachment} + payment={quote.payment} isIncoming={isIncoming} authorTitle={quote.authorTitle} bodyRanges={quote.bodyRanges} conversationColor={conversationColor} + conversationTitle={conversationTitle} customColor={customColor} isViewOnce={isViewOnce} isGiftBadge={isGiftBadge} @@ -1459,6 +1511,7 @@ export class Message extends React.PureComponent { public renderStoryReplyContext(): JSX.Element | null { const { + conversationTitle, conversationColor, customColor, direction, @@ -1483,6 +1536,7 @@ export class Message extends React.PureComponent { { {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; +}