Init payments message types

This commit is contained in:
Jamie Kyle 2022-11-30 13:47:54 -08:00 committed by GitHub
parent 0c4b52a125
commit 6198b02640
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 741 additions and 185 deletions

View file

@ -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 payments status",
"description": "Payment event notification check device label"
},
"SignalConnectionsModal__title": {
"message": "Signal Connections",
"description": "The phrase/term: 'Signal Connections'"

View file

@ -0,0 +1,5 @@
<svg width="16" height="14" viewBox="0 0 16 14" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.35 1H2.65C2.07978 1 1.53292 1.24055 1.12972 1.66873C0.726517 2.09691 0.5 2.67765 0.5 3.28319V10.7168C0.5 11.3224 0.726517 11.9031 1.12972 12.3313C1.53292 12.7595 2.07978 13 2.65 13H13.35C13.9202 13 14.4671 12.7595 14.8703 12.3313C15.2735 11.9031 15.5 11.3224 15.5 10.7168V3.28319C15.5 2.67765 15.2735 2.09691 14.8703 1.66873C14.4671 1.24055 13.9202 1 13.35 1ZM1.3 4.82301V3.28319C1.29987 3.09734 1.33508 2.91337 1.40351 2.74232C1.47195 2.57127 1.5722 2.41665 1.69827 2.28774C1.82433 2.15883 1.9736 2.05827 2.13714 1.9921C2.30068 1.92593 2.47512 1.89551 2.65 1.90266H13.35C13.5249 1.89551 13.6993 1.92593 13.8629 1.9921C14.0264 2.05827 14.1757 2.15883 14.3017 2.28774C14.4278 2.41665 14.5281 2.57127 14.5965 2.74232C14.6649 2.91337 14.7001 3.09734 14.7 3.28319V4.82301H1.3ZM2.65 12.0973C2.47512 12.1045 2.30068 12.0741 2.13714 12.0079C1.9736 11.9417 1.82433 11.8412 1.69827 11.7123C1.5722 11.5833 1.47195 11.4287 1.40351 11.2577C1.33508 11.0866 1.29987 10.9027 1.3 10.7168V6.52212H14.7V10.7168C14.7001 10.9027 14.6649 11.0866 14.5965 11.2577C14.5281 11.4287 14.4278 11.5833 14.3017 11.7123C14.1757 11.8412 14.0264 11.9417 13.8629 12.0079C13.6993 12.0741 13.5249 12.1045 13.35 12.0973H2.65Z"
fill="#5E5E5E" stroke="#5E5E5E" stroke-width="0.25" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -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;
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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 (
<CompositionArea
{...useProps({
quotedMessageProps: {
text: '',
conversationColor: ConversationColors[10],
conversationTitle: getDefaultConversation().title,
isGiftBadge: false,
isViewOnce: false,
referencedMessageNotFound: false,
authorTitle: 'Someone',
isFromMe: false,
payment: {
kind: PaymentEventKind.Notification,
note: 'Thanks',
},
},
})}
/>
);
}
QuoteWithPayment.story = {
name: 'Quote with payment',
};

View file

@ -906,6 +906,7 @@ export function StoryViewer({
{(currentViewTarget === StoryViewTargetType.Replies ||
currentViewTarget === StoryViewTargetType.Views) && (
<StoryViewsNRepliesModal
conversationTitle={group?.title ?? title}
authorTitle={firstName || title}
canReply={Boolean(canReply)}
getPreferredBadge={getPreferredBadge}

View file

@ -82,6 +82,7 @@ export enum StoryViewsNRepliesTab {
}
export type PropsType = {
conversationTitle: string;
authorTitle: string;
canReply: boolean;
getPreferredBadge: PreferredBadgeSelectorType;
@ -115,6 +116,7 @@ export type PropsType = {
};
export function StoryViewsNRepliesModal({
conversationTitle,
authorTitle,
canReply,
getPreferredBadge,
@ -223,6 +225,7 @@ export function StoryViewsNRepliesModal({
<Quote
authorTitle={authorTitle}
conversationColor="ultramarine"
conversationTitle={conversationTitle}
i18n={i18n}
isFromMe={false}
isGiftBadge={false}

View file

@ -83,6 +83,10 @@ import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations';
import { BadgeImageTheme } from '../../badges/BadgeImageTheme';
import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath';
import { handleOutsideClick } from '../../util/handleOutsideClick';
import { PaymentEventKind } from '../../types/Payment';
import type { AnyPaymentEvent } from '../../types/Payment';
import { Emojify } from './Emojify';
import { getPaymentEventDescription } from '../../messages/helpers';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
@ -216,11 +220,14 @@ export type PropsData = {
conversationType: ConversationTypeType;
attachments?: Array<AttachmentType>;
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<Props, State> {
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 (
<div
className={`module-payment-notification__container ${
direction === 'outgoing'
? `module-payment-notification--outgoing module-payment-notification--outgoing-${conversationColor}`
: ''
}`}
>
<p className="module-payment-notification__label">
{getPaymentEventDescription(
payment,
author.title,
conversationTitle,
author.isMe,
i18n
)}
</p>
<p className="module-payment-notification__check_device_box">
{i18n('icu:payment-event-notification-check-primary-device')}
</p>
{payment.note != null && (
<p className="module-payment-notification__note">
<Emojify text={payment.note} />
</p>
)}
</div>
);
}
public renderQuote(): JSX.Element | null {
const {
conversationColor,
conversationTitle,
customColor,
direction,
disableScroll,
@ -1441,10 +1491,12 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
public renderStoryReplyContext(): JSX.Element | null {
const {
conversationTitle,
conversationColor,
customColor,
direction,
@ -1483,6 +1536,7 @@ export class Message extends React.PureComponent<Props, State> {
<Quote
authorTitle={storyReplyContext.authorTitle}
conversationColor={conversationColor}
conversationTitle={conversationTitle}
customColor={customColor}
i18n={i18n}
isFromMe={storyReplyContext.isFromMe}
@ -2169,6 +2223,7 @@ export class Message extends React.PureComponent<Props, State> {
{this.renderStoryReplyContext()}
{this.renderAttachment()}
{this.renderPreview()}
{this.renderPayment()}
{this.renderEmbeddedContact()}
{this.renderText()}
{this.renderMetadata()}

View file

@ -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 (
<SystemMessage icon="payment-event" contents={<Emojify text={message} />} />
);
}

View file

@ -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,
},
};

View file

@ -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<Props, State> {
);
}
public renderPayment(): JSX.Element | null {
const { payment, authorTitle, conversationTitle, isFromMe, i18n } =
this.props;
if (payment == null) {
return null;
}
return (
<>
<Emojify text="💳" />
{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<Props, State> {
<div className={this.getClassName('__primary')}>
{this.renderAuthor()}
{this.renderGenericFile()}
{this.renderPayment()}
{this.renderText()}
</div>
{reactionEmoji && (

View file

@ -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<string, TimelineItemType> = {
'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<TimelineMessageProps>
): 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<string, TimelineItemType> = {
'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<string, TimelineItemType> = {
},
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<string, TimelineItemType> = {
data: null,
timestamp: Date.now(),
},
'id-10': {
type: 'message',
data: {
author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReact: true,
canReply: true,
canRetry: true,
canRetryDeleteForEveryone: true,
'id-10': mockMessageTimelineItem('id-10', {
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(),
},
timestamp: Date.now(),
},
'id-11': {
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',
}),
'id-11': mockMessageTimelineItem('id-11', {
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(),
},
timestamp: Date.now(),
},
'id-12': {
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',
}),
'id-12': mockMessageTimelineItem('id-12', {
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(),
},
timestamp: Date.now(),
},
'id-13': {
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',
}),
'id-13': mockMessageTimelineItem('id-13', {
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(),
},
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',
}),
'id-14': mockMessageTimelineItem('id-14', {
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-15': {
type: 'paymentEvent',
data: {
event: {
kind: PaymentEventKind.ActivationRequest,
},
sender: getDefaultConversation(),
conversation: getDefaultConversation(),
},
timestamp: Date.now(),
},
'id-16': {
type: 'paymentEvent',
data: {
event: {
kind: PaymentEventKind.Activation,
},
sender: getDefaultConversation(),
conversation: getDefaultConversation(),
},
timestamp: Date.now(),
},
'id-17': {
type: 'paymentEvent',
data: {
event: {
kind: PaymentEventKind.ActivationRequest,
},
sender: getDefaultConversation({
isMe: true,
}),
conversation: getDefaultConversation(),
},
timestamp: Date.now(),
},
'id-18': {
type: 'paymentEvent',
data: {
event: {
kind: PaymentEventKind.Activation,
},
sender: getDefaultConversation({
isMe: true,
}),
conversation: getDefaultConversation(),
},
timestamp: Date.now(),
},
'id-19': mockMessageTimelineItem('id-19', {
direction: 'outgoing',
status: 'read',
payment: {
kind: PaymentEventKind.Notification,
note: 'Thanks',
},
}),
};
const actions = () => ({

View file

@ -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,

View file

@ -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<PaymentEventNotificationPropsType, 'i18n'>;
};
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<PropsType> {
i18n={i18n}
/>
);
} else if (item.type === 'paymentEvent') {
notification = (
<PaymentEventNotification
{...this.props}
{...item.data}
i18n={i18n}
/>
);
} 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

View file

@ -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',
},
};

View file

@ -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;

View file

@ -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<MessageAttributesType, 'type'>
@ -26,6 +30,84 @@ export function isStory(message: Pick<MessageAttributesType, 'type'>): 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,

3
ts/model-types.d.ts vendored
View file

@ -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<any>;
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<MessageReactionType>;
requiredProtocolVersion?: number;

View file

@ -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),

View file

@ -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<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
hasAttachment ||
hasEmbeddedContact ||
isSticker ||
isPayment ||
// Rendered sync messages
isCallHistoryValue ||
isChatSessionRefreshedValue ||
@ -2094,6 +2114,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
!isStory(message.attributes) &&
!isGroupStoryReply &&
(!conversationTimestamp ||
message.get('sent_at') > conversationTimestamp)
message.get('sent_at') > conversationTimestamp) &&
messageHasPaymentEvent(message.attributes)
) {
conversation.set({
lastMessage: message.getNotificationText(),

View file

@ -273,7 +273,7 @@ function setMediaQualitySetting(
}
function setQuotedMessage(
payload?: Pick<MessageAttributesType, 'conversationId' | 'quote'>
payload?: Pick<MessageAttributesType, 'conversationId' | 'quote' | 'payment'>
): SetQuotedMessageActionType {
return {
type: SET_QUOTED_MESSAGE,

View file

@ -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<MessageWithUIFieldsType, 'conversationId' | 'quote'>,
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<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> => {
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<PaymentEventNotificationPropsType, 'i18n'> {
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,

View file

@ -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<ProcessedContact>;
preview?: ReadonlyArray<ProcessedPreview>;

View file

@ -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),

34
ts/types/Payment.ts Normal file
View file

@ -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;
}