diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d901c2cdeba4..ae35a7e01f80 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2715,6 +2715,102 @@ "message": "This message was deleted.", "description": "Shown in a message's bubble when the message has been deleted for everyone." }, + "message--giftBadge--unopened": { + "message": "View this message on mobile to open it", + "description": "Shown in a message's bubble when you've received a gift badge from a contact" + }, + "message--giftBadge--unopened--label": { + "message": "Gift", + "description": "Shown in a message's bubble when you've received a gift badge from a contact" + }, + "message--giftBadge--unopened--toast--incoming": { + "message": "Check your phone to open gift", + "description": "Shown when you've clicked on an incoming gift badge you haven't yet redeemed" + }, + "message--giftBadge--unopened--toast--outgoing": { + "message": "Check your phone to view your gift", + "description": "Shown when you've clicked on an outgoing gift badge" + }, + "message--giftBadge--preview--unopened": { + "message": "You received a gift", + "description": "Shown to label the gift badge in notifications and the left pane" + }, + "message--giftBadge--preview--redeemed": { + "message": "You redeemed a gift badge", + "description": "Shown to label the redeemed gift badge in notifications and the left pane" + }, + "message--giftBadge--preview--sent": { + "message": "You sent a gift badge", + "description": "Shown to label a gift badge you've sent in notifications and the left pane" + }, + "message--giftBadge": { + "message": "Gift Badge", + "description": "Shown to label the gift badge you've redeemed on another device" + }, + "quote--giftBadge": { + "message": "Gift", + "description": "Shown to label a gift badge you've replied to" + }, + "message--giftBadge--remaining--days": { + "message": "$days$ days remaining", + "description": "Describes how long remains for the gift badge you've redeemed on another device (only rendered for days > 1)", + "placeholders": { + "days": { + "content": "$1", + "example": "58" + } + } + }, + "message--giftBadge--remaining--hours": { + "message": "$hours$ hours remaining", + "description": "Describes how long remains for the gift badge you've redeemed on another device (only rendered for hours > 1)", + "placeholders": { + "hours": { + "content": "$1", + "example": "23" + } + } + }, + "message--giftBadge--remaining--minutes": { + "message": "$minutes$ minutes remaining", + "description": "Describes how long remains for the gift badge you've redeemed on another device (only rendered for minutes > 1)", + "placeholders": { + "minutes": { + "content": "$1", + "example": "45" + } + } + }, + "message--giftBadge--remaining--one-minute": { + "message": "1 minute remaining", + "description": "Describes how long remains for the gift badge you've redeemed on another device" + }, + "message--giftBadge--expired": { + "message": "Expired", + "description": "Shows that a gift badge is expired" + }, + "message--giftBadge--view": { + "message": "View", + "description": "Shown when you've sent a gift badge to someone then opened it" + }, + "message--giftBadge--redeemed": { + "message": "Redeemed", + "description": "Shown when you've redeemed the gift badge on another device" + }, + "modal--giftBadge--title": { + "message": "Thanks for your support!", + "description": "The title of the outgoing gift badge detail dialog" + }, + "modal--giftBadge--description": { + "message": "You've gifted a badge to $name$. When they accept, they'll be given a choice to show or hide their badge.", + "description": "The description of the outgoing gift badge detail dialog", + "placeholders": { + "name": { + "content": "$1", + "example": "Paige Hall" + } + } + }, "stickers--toast--InstallFailed": { "message": "Sticker pack could not be installed", "description": "Shown in a toast if the user attempts to install a sticker pack and it fails" diff --git a/images/gift-bow.svg b/images/gift-bow.svg new file mode 100644 index 000000000000..2798d1bfc46b --- /dev/null +++ b/images/gift-bow.svg @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/images/gift-thumbnail.svg b/images/gift-thumbnail.svg new file mode 100644 index 000000000000..2199099269e8 --- /dev/null +++ b/images/gift-thumbnail.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 1181dc8f2f02..8b5b34887cba 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -128,6 +128,11 @@ message DataMessage { } message Quote { + enum Type { + NORMAL = 0; + GIFT_BADGE = 1; + } + message QuotedAttachment { optional string contentType = 1; optional string fileName = 2; @@ -140,6 +145,7 @@ message DataMessage { optional string text = 3; repeated QuotedAttachment attachments = 4; repeated BodyRange bodyRanges = 6; + optional Type type = 7; } message Contact { @@ -269,6 +275,10 @@ message DataMessage { CURRENT = 7; } + message GiftBadge { + optional bytes receiptCredentialPresentation = 1; + } + optional string body = 1; repeated AttachmentPointer attachments = 2; optional GroupContext group = 3; @@ -289,6 +299,7 @@ message DataMessage { optional GroupCallUpdate groupCallUpdate = 19; reserved /* Payment payment */ 20; optional StoryContext storyContext = 21; + optional GiftBadge giftBadge = 22; } message NullMessage { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 9fd844807b86..0c56b85b6154 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -301,6 +301,10 @@ max-width: 370px; } } + +$message-padding-vertical: 8px; +$message-padding-horizontal: 12px; + .module-message__container { $collapsed-border-radius: 4px; @@ -312,12 +316,11 @@ min-width: 0px; overflow: hidden; - // These should match the margins in .module-message__attachment-container. padding: { - left: 12px; - right: 12px; - top: 8px; - bottom: 8px; + left: $message-padding-horizontal; + right: $message-padding-horizontal; + top: $message-padding-vertical; + bottom: $message-padding-vertical; } .module-message--collapsed-above & { @@ -563,13 +566,11 @@ text-align: center; position: relative; - // These should match the paddings from .module-message__container, - // effectively "undoing" that padding. margin: { - left: -12px; - right: -12px; - top: -8px; - bottom: -8px; + left: -$message-padding-horizontal; + right: -$message-padding-horizontal; + top: -$message-padding-vertical; + bottom: -$message-padding-vertical; } line-height: 0; @@ -596,10 +597,10 @@ text-align: center; margin: { - left: -12px; - right: -12px; - top: -9px; - bottom: -5px; + left: -$message-padding-horizontal; + right: -$message-padding-horizontal; + top: -$message-padding-vertical - 1px; + bottom: -$message-padding-vertical + 3px; } &--with-content-below { @@ -787,12 +788,12 @@ display: block; - margin-left: -12px; - margin-right: -12px; + margin-left: -$message-padding-horizontal; + margin-right: -$message-padding-horizontal; width: calc(100% + 24px); outline: none; - margin-top: -8px; + margin-top: -$message-padding-vertical; margin-bottom: 5px; overflow: hidden; @@ -808,7 +809,7 @@ } .module-message__link-preview__content { - padding: 8px 12px; + padding: $message-padding-vertical $message-padding-horizontal; display: flex; flex-direction: row; align-items: flex-start; @@ -1219,10 +1220,10 @@ @include font-body-2-bold; - margin-top: 8px; - margin-bottom: -8px; - margin-left: -12px; - margin-right: -12px; + margin-top: $message-padding-vertical; + margin-bottom: -$message-padding-vertical; + margin-left: -$message-padding-horizontal; + margin-right: -$message-padding-horizontal; text-align: center; padding: 10px; @@ -1267,6 +1268,301 @@ } } +.module-message__unopened-gift-badge__container { + cursor: pointer; + user-select: none; +} + +.module-message__unopened-gift-badge { + width: 240px; + height: 132px; + background-color: $color-ultramarine; + position: relative; + + margin: { + left: -$message-padding-horizontal; + right: -$message-padding-horizontal; + top: -$message-padding-vertical; + bottom: $message-padding-vertical; + } +} + +.module-message__unopened-gift-badge--outgoing { + @include light-theme { + border-bottom: 1px solid $color-white-alpha-80; + } + @include dark-theme { + border-bottom: 1px solid $color-gray-95; + } +} + +.module-message__unopened-gift-badge__ribbon-horizontal { + position: absolute; + left: 0; + right: 0; + height: 16px; + top: 50%; + transform: translateY(-50%); + background-color: $color-white; +} +.module-message__unopened-gift-badge__ribbon-vertical { + position: absolute; + top: 0; + bottom: 0; + width: 16px; + left: 50%; + transform: translateX(-50%); + background-color: $color-white; +} +.module-message__unopened-gift-badge__bow { + position: absolute; + + // Centered + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + // For proper alignment with the ribbons + margin-top: 3px; + + // 75.26px by 51.93px in Figma, but there's a buffer in the SVG file + width: 81px; + height: 60px; +} + +.module-message__unopened-gift-badge__text { + @include font-body-2; +} +.module-message__unopened-gift-badge__text--incoming { + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } +} +.module-message__unopened-gift-badge__container + .module-message__text--incoming { + @include font-body-2; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } +} +.module-message__unopened-gift-badge__container + .module-message__text--outgoing { + @include font-body-2; + + @include light-theme { + color: $color-white-alpha-80; + } + @include dark-theme { + color: $color-white-alpha-80; + } +} + +.module-message__redeemed-gift-badge { + display: flex; + flex-direction: row; + + &__container { + user-select: none; + } + + &__badge { + height: 64px; + width: 64px; + margin-left: 4px; + margin-top: 8px; + margin-right: 12px; + margin-bottom: 16px; + flex-grow: 0; + flex-shrink: 0; + + &--missing-incoming { + border-radius: 50%; + + @include light-theme { + background-color: $color-gray-15; + } + @include dark-theme { + background-color: $color-gray-60; + } + } + &--missing-outgoing { + border-radius: 50%; + + @include light-theme { + background-color: $color-white-alpha-20; + } + @include dark-theme { + background-color: $color-white-alpha-20; + } + } + } + + &__text { + flex-grow: 1; + margin-top: 19px; + } + &__title { + margin-bottom: 6px; + @include font-body-1; + } + &__remaining { + @include font-subtitle; + + &--incoming { + @include light-theme { + color: $color-gray-75; + } + @include dark-theme { + color: $color-gray-25; + } + } + &--outgoing { + @include light-theme { + color: $color-white-alpha-80; + } + @include dark-theme { + color: $color-white-alpha-80; + } + } + } + + &__button { + @include button-reset; + @include button-secondary; + margin-left: auto; + margin-right: auto; + width: 216px; + margin-bottom: 7px; + text-align: center; + border-radius: 4px; + + @include font-body-1-bold; + + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + + &--incoming { + @include light-theme { + color: $color-gray-90; + background-color: $color-white; + } + @include dark-theme { + color: $color-gray-05; + background-color: $color-gray-62; + } + + // Disabling hover + &:hover { + @include mouse-mode { + background-color: $color-white; + } + + @include dark-mouse-mode { + background-color: $color-gray-62; + } + } + } + + &--outgoing { + @include light-theme { + color: $color-gray-90; + background-color: $color-white-alpha-80; + } + @include dark-theme { + color: $color-gray-90; + background-color: $color-white-alpha-80; + } + &:hover { + @include mouse-mode { + background-color: $color-white-alpha-90; + } + + @include dark-mouse-mode { + background-color: $color-white-alpha-90; + } + } + &:focus { + @include keyboard-mode { + box-shadow: 0px 0px 0px 3px $color-ultramarine-light; + } + @include dark-keyboard-mode { + box-shadow: 0px 0px 0px 3px $color-ultramarine-light; + } + } + &:active { + // We need to include all four here for specificity precedence + + @include mouse-mode { + background-color: $color-white; + } + @include dark-mouse-mode { + background-color: $color-white; + } + + @include keyboard-mode { + background-color: $color-white; + } + @include dark-keyboard-mode { + background-color: $color-white; + } + } + } + + &__text { + display: flex; + flex-direction: row; + align-items: center; + height: 36px; + } + } + + &__icon-check { + height: 19px; + width: 19px; + margin-right: 5px; + display: inline-block; + + &--incoming { + @include light-theme { + @include color-svg( + '../images/icons/v2/check-circle-outline-24.svg', + $color-gray-90 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/check-circle-outline-24.svg', + $color-gray-05 + ); + } + } + + &--outgoing { + @include light-theme { + @include color-svg( + '../images/icons/v2/check-circle-outline-24.svg', + $color-gray-90 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/check-circle-outline-24.svg', + $color-gray-90 + ); + } + } + } +} + .module-message__typing-container { height: 16px; diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index df7efbf5343c..77c3a6c89380 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -20,6 +20,7 @@ $color-gray-20: #c6c6c6; $color-gray-25: #b9b9b9; $color-gray-45: #848484; $color-gray-60: #5e5e5e; +$color-gray-62: #545454; $color-gray-65: #4a4a4a; $color-gray-75: #3b3b3b; $color-gray-80: #2e2e2e; diff --git a/stylesheets/components/OutgoingGiftBadgeModal.scss b/stylesheets/components/OutgoingGiftBadgeModal.scss new file mode 100644 index 000000000000..8cf5ffc44dc5 --- /dev/null +++ b/stylesheets/components/OutgoingGiftBadgeModal.scss @@ -0,0 +1,46 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.OutgoingGiftBadgeModal { + text-align: center; + + &__container { + width: 420px; + max-width: 420px; + } + + &__title { + @include font-title-2; + margin-top: 5px; + } + &__description { + @include font-body-1; + margin-top: 8px; + margin-left: auto; + margin-right: auto; + width: 328px; + } + &__badge { + margin-top: 34px; + height: 160px; + width: 160px; + + &--missing { + border-radius: 50%; + margin-left: auto; + margin-right: auto; + + @include light-theme { + background-color: $color-gray-05; + } + @include dark-theme { + background-color: $color-gray-60; + } + } + } + &__badge-summary { + margin-top: 16px; + margin-bottom: 16px; + @include font-body-1-bold; + } +} diff --git a/stylesheets/components/Quote.scss b/stylesheets/components/Quote.scss index e02f4a62354d..1ce7d0be1adb 100644 --- a/stylesheets/components/Quote.scss +++ b/stylesheets/components/Quote.scss @@ -63,7 +63,6 @@ .module-quote--outgoing { border-left-color: $color-steel; background-color: $color-steel; - margin-top: -4px; // To preserve contrast @include keyboard-mode { @@ -126,14 +125,6 @@ } } -.module-quote--curve-top-left { - border-top-left-radius: 12px; -} - -.module-quote--curve-top-right { - border-top-right-radius: 12px; -} - .module-quote__primary { flex-grow: 1; padding-left: 8px; @@ -265,6 +256,18 @@ flex: 0 0 54px; position: relative; width: 54px; + + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} + +.module-quote__icon-container__outgoing-gift-badge { + @include light-theme { + border: 1px solid $color-white; + } + @include dark-theme { + border: 1px solid $color-white-alpha-80; + } } .module-quote__icon-container__inner { diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index abc3e985431b..eac5145a192f 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -88,6 +88,7 @@ @import './components/MessageDetail.scss'; @import './components/Modal.scss'; @import './components/MyStories.scss'; +@import './components/OutgoingGiftBadgeModal.scss'; @import './components/PermissionsPopup.scss'; @import './components/Preferences.scss'; @import './components/ProfileEditor.scss'; diff --git a/ts/background.ts b/ts/background.ts index 0b172269c177..c77dd0338f60 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2096,6 +2096,7 @@ export async function startApp(): Promise { await Promise.all([ server.registerCapabilities({ announcementGroup: true, + giftBadges: true, 'gv2-3': true, 'gv1-migration': true, senderKey: true, diff --git a/ts/badges/parseBadgesFromServer.ts b/ts/badges/parseBadgesFromServer.ts index dc62df792cb6..b9067990d9f8 100644 --- a/ts/badges/parseBadgesFromServer.ts +++ b/ts/badges/parseBadgesFromServer.ts @@ -23,6 +23,96 @@ const badgeFromServerSchema = z.object({ visible: z.boolean().optional(), }); +// GET /v1/subscription/boost/badges +const boostBadgesFromServerSchema = z.object({ + levels: z.record( + z + .object({ + badge: z.unknown(), + }) + .or(z.undefined()) + ), +}); + +export function parseBoostBadgeListFromServer( + value: unknown, + updatesUrl: string +): Record { + const result: Record = {}; + + const parseResult = boostBadgesFromServerSchema.safeParse(value); + if (!parseResult.success) { + log.warn( + 'parseBoostBadgeListFromServer: server response was invalid:', + parseResult.error.format() + ); + throw new Error( + 'parseBoostBadgeListFromServer: Failed to parse server response' + ); + } + + const boostBadges = parseResult.data; + Object.keys(boostBadges.levels).forEach(level => { + const item = boostBadges.levels[level]; + if (!item) { + log.warn(`parseBoostBadgeListFromServer: level ${level} had no badge`); + return; + } + + const parsed = parseBadgeFromServer(item.badge, updatesUrl); + + if (parsed) { + result[`BOOST-${level}`] = parsed; + } + }); + + return result; +} + +export function parseBadgeFromServer( + value: unknown, + updatesUrl: string +): BadgeType | undefined { + const parseResult = badgeFromServerSchema.safeParse(value); + if (!parseResult.success) { + log.warn( + 'parseBadgeFromServer: badge was invalid:', + parseResult.error.format() + ); + return undefined; + } + + const { + category, + description: descriptionTemplate, + expiration, + id, + name, + svg, + svgs, + visible, + } = parseResult.data; + const images = parseImages(svgs, svg, updatesUrl); + if (images.length !== 4) { + log.warn('Got invalid number of SVGs from the server'); + return undefined; + } + + return { + id, + category: parseBadgeCategory(category), + name, + descriptionTemplate, + images, + ...(isNormalNumber(expiration) && typeof visible === 'boolean' + ? { + expiresAt: expiration * 1000, + isVisible: visible, + } + : {}), + }; +} + export function parseBadgesFromServer( value: unknown, updatesUrl: string @@ -36,45 +126,13 @@ export function parseBadgesFromServer( const numberOfBadgesToParse = Math.min(value.length, MAX_BADGES); for (let i = 0; i < numberOfBadgesToParse; i += 1) { const item = value[i]; + const parsed = parseBadgeFromServer(item, updatesUrl); - const parseResult = badgeFromServerSchema.safeParse(item); - if (!parseResult.success) { - log.warn( - 'parseBadgesFromServer got an invalid item', - parseResult.error.format() - ); + if (!parsed) { continue; } - const { - category, - description: descriptionTemplate, - expiration, - id, - name, - svg, - svgs, - visible, - } = parseResult.data; - const images = parseImages(svgs, svg, updatesUrl); - if (images.length !== 4) { - log.warn('Got invalid number of SVGs from the server'); - continue; - } - - result.push({ - id, - category: parseBadgeCategory(category), - name, - descriptionTemplate, - images, - ...(isNormalNumber(expiration) && typeof visible === 'boolean' - ? { - expiresAt: expiration * 1000, - isVisible: visible, - } - : {}), - }); + result.push(parsed); } return result; diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index c5e40ae622a3..79ebc77d6deb 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -191,6 +191,7 @@ story.add('Quote', () => ( quotedMessageProps: { text: 'something', conversationColor: ConversationColors[10], + isGiftBadge: false, isViewOnce: false, referencedMessageNotFound: false, authorTitle: 'Someone', diff --git a/ts/components/OutgoingGiftBadgeModal.stories.tsx b/ts/components/OutgoingGiftBadgeModal.stories.tsx new file mode 100644 index 000000000000..01e376935112 --- /dev/null +++ b/ts/components/OutgoingGiftBadgeModal.stories.tsx @@ -0,0 +1,57 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { text } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; + +import type { PropsType } from './OutgoingGiftBadgeModal'; +import { OutgoingGiftBadgeModal } from './OutgoingGiftBadgeModal'; + +import { setupI18n } from '../util/setupI18n'; +import enMessages from '../../_locales/en/messages.json'; +import { BadgeCategory } from '../badges/BadgeCategory'; + +const i18n = setupI18n('en', enMessages); + +const getPreferredBadge = () => ({ + category: BadgeCategory.Donor, + descriptionTemplate: 'This is a description of the badge', + id: 'BOOST-3', + images: [ + { + transparent: { + localPath: '/fixtures/orange-heart.svg', + url: 'http://someplace', + }, + }, + ], + name: 'heart', +}); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + recipientTitle: text( + 'recipientTitle', + overrideProps.recipientTitle || 'Default Name' + ), + badgeId: text('badgeId', overrideProps.badgeId || 'heart'), + getPreferredBadge, + hideOutgoingGiftBadgeModal: action('hideOutgoingGiftBadgeModal'), + i18n, +}); + +const story = storiesOf('Components/OutgoingGiftBadgeModal', module); + +story.add('Normal', () => { + return ; +}); + +story.add('Missing badge', () => { + const props = { + ...createProps(), + getPreferredBadge: () => undefined, + }; + + return ; +}); diff --git a/ts/components/OutgoingGiftBadgeModal.tsx b/ts/components/OutgoingGiftBadgeModal.tsx new file mode 100644 index 000000000000..71f8c85b6f3a --- /dev/null +++ b/ts/components/OutgoingGiftBadgeModal.tsx @@ -0,0 +1,77 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import classNames from 'classnames'; + +import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath'; +import { Modal } from './Modal'; +import { BadgeImageTheme } from '../badges/BadgeImageTheme'; + +import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; +import type { LocalizerType } from '../types/Util'; + +const CLASS_NAME = 'OutgoingGiftBadgeModal'; + +export type PropsType = { + recipientTitle: string; + i18n: LocalizerType; + badgeId: string; + hideOutgoingGiftBadgeModal: () => unknown; + getPreferredBadge: PreferredBadgeSelectorType; +}; + +export const OutgoingGiftBadgeModal = ({ + recipientTitle, + i18n, + badgeId, + hideOutgoingGiftBadgeModal, + getPreferredBadge, +}: PropsType): JSX.Element => { + const badge = getPreferredBadge([{ id: badgeId }]); + const badgeSize = 140; + const badgeImagePath = getBadgeImageFileLocalPath( + badge, + badgeSize, + BadgeImageTheme.Transparent + ); + + const badgeElement = badge ? ( + {badge.name} + ) : ( +
+ ); + + return ( + +
+
+ {i18n('modal--giftBadge--title')} +
+
+ {i18n('modal--giftBadge--description', { name: recipientTitle })} +
+ {badgeElement} +
+ {i18n('message--giftBadge')} +
+
+
+ ); +}; diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index 192e623e6603..9e0c8b9e9aea 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -143,6 +143,7 @@ export const StoryViewsNRepliesModal = ({ conversationColor="ultramarine" i18n={i18n} isFromMe={false} + isGiftBadge={false} isStoryReply isViewOnce={false} moduleClassName="StoryViewsNRepliesModal__quote" diff --git a/ts/components/ToastCannotOpenGiftBadge.tsx b/ts/components/ToastCannotOpenGiftBadge.tsx new file mode 100644 index 000000000000..540a4791a332 --- /dev/null +++ b/ts/components/ToastCannotOpenGiftBadge.tsx @@ -0,0 +1,24 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import type { LocalizerType } from '../types/Util'; +import { Toast } from './Toast'; + +export type ToastPropsType = { + i18n: LocalizerType; + isIncoming: boolean; + onClose: () => unknown; +}; + +export const ToastCannotOpenGiftBadge = ({ + i18n, + isIncoming, + onClose, +}: ToastPropsType): JSX.Element => { + const key = `message--giftBadge--unopened--toast--${ + isIncoming ? 'incoming' : 'outgoing' + }`; + + return {i18n(key)}; +}; diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 7982e53f25eb..aaee08b52d9e 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -12,7 +12,7 @@ import { SignalService } from '../../protobuf'; import { ConversationColors } from '../../types/Colors'; import { EmojiPicker } from '../emoji/EmojiPicker'; import type { Props, AudioAttachmentProps } from './Message'; -import { TextDirection, Message } from './Message'; +import { GiftBadgeStates, Message, TextDirection } from './Message'; import { AUDIO_MP3, IMAGE_JPEG, @@ -30,7 +30,7 @@ import enMessages from '../../../_locales/en/messages.json'; import { pngUrl } from '../../storybook/Fixtures'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { WidthBreakpoint } from '../_util'; -import { MINUTE } from '../../util/durations'; +import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations'; import { ContactFormType } from '../../types/EmbeddedContact'; import { @@ -40,6 +40,7 @@ import { import { getFakeBadge } from '../../test-both/helpers/getFakeBadge'; import { ThemeType } from '../../types/Util'; import { UUID } from '../../types/UUID'; +import { BadgeCategory } from '../../badges/BadgeCategory'; const i18n = setupI18n('en', enMessages); @@ -119,6 +120,9 @@ const createProps = (overrideProps: Partial = {}): Props => ({ conversationColor: overrideProps.conversationColor || select('conversationColor', ConversationColors, ConversationColors[0]), + conversationTitle: + overrideProps.conversationTitle || + text('conversationTitle', 'Conversation Title'), conversationId: text('conversationId', overrideProps.conversationId || ''), conversationType: overrideProps.conversationType || 'direct', contact: overrideProps.contact, @@ -138,8 +142,9 @@ const createProps = (overrideProps: Partial = {}): Props => ({ number('expirationTimestamp', overrideProps.expirationTimestamp || 0) || undefined, getPreferredBadge: overrideProps.getPreferredBadge || (() => undefined), + giftBadge: overrideProps.giftBadge, i18n, - id: text('id', overrideProps.id || ''), + id: text('id', overrideProps.id || 'random-message-id'), renderingContext: 'storybook', interactionMode: overrideProps.interactionMode || 'keyboard', isSticker: isBoolean(overrideProps.isSticker) @@ -159,6 +164,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ markViewed: action('markViewed'), messageExpanded: action('messageExpanded'), openConversation: action('openConversation'), + openGiftBadge: action('openGiftBadge'), openLink: action('openLink'), previews: overrideProps.previews || [], reactions: overrideProps.reactions, @@ -1218,6 +1224,7 @@ story.add('Other File Type', () => { contentType: stringToMIMEType('text/plain'), fileName: 'my-resume.txt', url: 'my-resume.txt', + fileSize: '10MB', }), ], status: 'sent', @@ -1233,6 +1240,7 @@ story.add('Other File Type with Caption', () => { contentType: stringToMIMEType('text/plain'), fileName: 'my-resume.txt', url: 'my-resume.txt', + fileSize: '10MB', }), ], status: 'sent', @@ -1250,6 +1258,7 @@ story.add('Other File Type with Long Filename', () => { fileName: 'INSERT-APP-NAME_INSERT-APP-APPLE-ID_AppStore_AppsGamesWatch.psd.zip', url: 'a2/a2334324darewer4234', + fileSize: '10MB', }), ], status: 'sent', @@ -1714,3 +1723,101 @@ story.add('EmbeddedContact: Loading Avatar', () => { }); return renderBothDirections(props); }); + +story.add('Gift Badge: Unopened', () => { + const props = createProps({ + giftBadge: { + state: GiftBadgeStates.Unopened, + expiration: Date.now() + DAY * 30, + level: 3, + }, + }); + return renderBothDirections(props); +}); + +const getPreferredBadge = () => ({ + category: BadgeCategory.Donor, + descriptionTemplate: 'This is a description of the badge', + id: 'BOOST-3', + images: [ + { + transparent: { + localPath: '/fixtures/orange-heart.svg', + url: 'http://someplace', + }, + }, + ], + name: 'heart', +}); + +story.add('Gift Badge: Redeemed (30 days)', () => { + const props = createProps({ + getPreferredBadge, + giftBadge: { + state: GiftBadgeStates.Redeemed, + expiration: Date.now() + DAY * 30 + SECOND, + level: 3, + }, + }); + return renderBothDirections(props); +}); + +story.add('Gift Badge: Redeemed (24 hours)', () => { + const props = createProps({ + getPreferredBadge, + giftBadge: { + state: GiftBadgeStates.Redeemed, + expiration: Date.now() + DAY + SECOND, + level: 3, + }, + }); + return renderBothDirections(props); +}); + +story.add('Gift Badge: Redeemed (60 minutes)', () => { + const props = createProps({ + getPreferredBadge, + giftBadge: { + state: GiftBadgeStates.Redeemed, + expiration: Date.now() + HOUR + SECOND, + level: 3, + }, + }); + return renderBothDirections(props); +}); + +story.add('Gift Badge: Redeemed (1 minute)', () => { + const props = createProps({ + getPreferredBadge, + giftBadge: { + state: GiftBadgeStates.Redeemed, + expiration: Date.now() + MINUTE + SECOND, + level: 3, + }, + }); + return renderBothDirections(props); +}); + +story.add('Gift Badge: Redeemed (expired)', () => { + const props = createProps({ + getPreferredBadge, + giftBadge: { + state: GiftBadgeStates.Redeemed, + expiration: Date.now(), + level: 3, + }, + }); + return renderBothDirections(props); +}); + +story.add('Gift Badge: Missing Badge', () => { + const props = createProps({ + getPreferredBadge: () => undefined, + giftBadge: { + state: GiftBadgeStates.Redeemed, + expiration: Date.now() + MINUTE + SECOND, + level: 3, + }, + }); + return renderBothDirections(props); +}); diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 28471f1b3855..8e760e906d3c 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -5,6 +5,7 @@ import type { ReactNode, RefObject } from 'react'; import React from 'react'; import ReactDOM, { createPortal } from 'react-dom'; import classNames from 'classnames'; +import getDirection from 'direction'; import { drop, groupBy, orderBy, take, unescape } from 'lodash'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; import { Manager, Popper, Reference } from 'react-popper'; @@ -41,6 +42,7 @@ import { LinkPreviewDate } from './LinkPreviewDate'; import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage'; import { WidthBreakpoint } from '../_util'; +import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal'; import * as log from '../../logging/log'; import type { AttachmentType } from '../../types/Attachment'; @@ -69,6 +71,7 @@ import type { LocalizerType, ThemeType, } from '../../types/Util'; + import type { PreferredBadgeSelectorType } from '../../state/selectors/badges'; import type { ContactNameColorType, @@ -84,6 +87,9 @@ import { offsetDistanceModifier } from '../../util/popperUtil'; import * as KeyboardLayout from '../../services/keyboardLayout'; import { StopPropagation } from '../StopPropagation'; import type { UUIDStringType } from '../../types/UUID'; +import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations'; +import { BadgeImageTheme } from '../../badges/BadgeImageTheme'; +import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath'; type Trigger = { handleContextClick: (event: React.MouseEvent) => void; @@ -116,6 +122,7 @@ const SENT_STATUSES = new Set([ 'sent', 'viewed', ]); +const GIFT_BADGE_UPDATE_INTERVAL = 30 * SECOND; enum MetadataPlacement { NotRendered, @@ -171,11 +178,22 @@ export type AudioAttachmentProps = { onFirstPlayed(): void; }; +export enum GiftBadgeStates { + Unopened = 'Unopened', + Redeemed = 'Redeemed', +} +export type GiftBadgeType = { + level: number; + expiration: number; + state: GiftBadgeStates.Redeemed | GiftBadgeStates.Unopened; +}; + export type PropsData = { id: string; renderingContext: string; contactNameColor?: ContactNameColorType; conversationColor: ConversationColorType; + conversationTitle: string; customColor?: CustomColorType; conversationId: string; displayLimit?: number; @@ -207,6 +225,7 @@ export type PropsData = { reducedMotion?: boolean; conversationType: ConversationTypeType; attachments?: Array; + giftBadge?: GiftBadgeType; quote?: { conversationColor: ConversationColorType; customColor?: CustomColorType; @@ -222,6 +241,7 @@ export type PropsData = { bodyRanges?: BodyRangesType; referencedMessageNotFound: boolean; isViewOnce: boolean; + isGiftBadge: boolean; }; storyReplyContext?: { authorTitle: string; @@ -299,6 +319,7 @@ export type PropsActions = { startConversation: (e164: string, uuid: UUIDStringType) => void; openConversation: (conversationId: string, messageId?: string) => void; + openGiftBadge: (messageId: string) => void; showContactDetail: (options: { contact: EmbeddedContactType; signalAccount?: { @@ -357,6 +378,9 @@ type State = { reactionViewerRoot: HTMLDivElement | null; reactionPickerRoot: HTMLDivElement | null; + giftBadgeCounter: number | null; + showOutgoingGiftBadgeModal: boolean; + hasDeleteForEveryoneTimerExpired: boolean; }; @@ -374,6 +398,8 @@ export class Message extends React.PureComponent { public expirationCheckInterval: NodeJS.Timeout | undefined; + public giftBadgeInterval: NodeJS.Timeout | undefined; + public expiredTimeout: NodeJS.Timeout | undefined; public selectedTimeout: NodeJS.Timeout | undefined; @@ -396,6 +422,9 @@ export class Message extends React.PureComponent { reactionViewerRoot: null, reactionPickerRoot: null, + giftBadgeCounter: null, + showOutgoingGiftBadgeModal: false, + hasDeleteForEveryoneTimerExpired: this.getTimeRemainingForDeleteForEveryone() <= 0, }; @@ -490,6 +519,7 @@ export class Message extends React.PureComponent { this.startSelectedTimer(); this.startDeleteForEveryoneTimerIfApplicable(); + this.startGiftBadgeInterval(); const { isSelected } = this.props; if (isSelected) { @@ -519,6 +549,7 @@ export class Message extends React.PureComponent { clearTimeoutIfNecessary(this.expirationCheckInterval); clearTimeoutIfNecessary(this.expiredTimeout); clearTimeoutIfNecessary(this.deleteForEveryoneTimeout); + clearTimeoutIfNecessary(this.giftBadgeInterval); this.toggleReactionViewer(true); this.toggleReactionPicker(true); } @@ -559,6 +590,8 @@ export class Message extends React.PureComponent { deletedForEveryone, expirationLength, expirationTimestamp, + giftBadge, + i18n, shouldHideMetadata, status, text, @@ -576,6 +609,17 @@ export class Message extends React.PureComponent { return MetadataPlacement.NotRendered; } + if (giftBadge) { + const description = i18n('message--giftBadge--unopened'); + const isDescriptionRTL = getDirection(description) === 'rtl'; + + if (giftBadge.state === GiftBadgeStates.Unopened && !isDescriptionRTL) { + return MetadataPlacement.InlineWithText; + } + + return MetadataPlacement.Bottom; + } + if (!text && !deletedForEveryone) { return isAudio(attachments) ? MetadataPlacement.RenderedByMessageAudioComponent @@ -635,6 +679,24 @@ export class Message extends React.PureComponent { } } + public startGiftBadgeInterval(): void { + const { giftBadge } = this.props; + + if (!giftBadge) { + return; + } + + this.giftBadgeInterval = setInterval(() => { + this.updateGiftBadgeCounter(); + }, GIFT_BADGE_UPDATE_INTERVAL); + } + + public updateGiftBadgeCounter(): void { + this.setState((state: State) => ({ + giftBadgeCounter: (state.giftBadgeCounter || 0) + 1, + })); + } + private getTimeRemainingForDeleteForEveryone(): number { const { timestamp } = this.props; return Math.max(timestamp - Date.now() + THREE_HOURS, 0); @@ -1054,17 +1116,17 @@ export class Message extends React.PureComponent { public renderPreview(): JSX.Element | null { const { - id, attachments, conversationType, direction, i18n, + id, + kickOffAttachmentDownload, openLink, previews, quote, shouldCollapseAbove, theme, - kickOffAttachmentDownload, } = this.props; // Attachments take precedence over Link Previews @@ -1205,6 +1267,188 @@ export class Message extends React.PureComponent { ); } + public renderGiftBadge(): JSX.Element | null { + const { conversationTitle, direction, getPreferredBadge, giftBadge, i18n } = + this.props; + const { showOutgoingGiftBadgeModal } = this.state; + if (!giftBadge) { + return null; + } + + if (giftBadge.state === GiftBadgeStates.Unopened) { + const description = i18n('message--giftBadge--unopened'); + const isRTL = getDirection(description) === 'rtl'; + const { metadataWidth } = this.state; + + return ( +
+
+
+
+ +
+
+
+ {description} + {this.getMetadataPlacement() === + MetadataPlacement.InlineWithText && ( + + )} +
+ {this.renderMetadata()} +
+
+ ); + } + + if (giftBadge.state === GiftBadgeStates.Redeemed) { + const badgeId = `BOOST-${giftBadge.level}`; + const badgeSize = 64; + const badge = getPreferredBadge([{ id: badgeId }]); + const badgeImagePath = getBadgeImageFileLocalPath( + badge, + badgeSize, + BadgeImageTheme.Transparent + ); + + let remaining: string; + const duration = giftBadge.expiration - Date.now(); + + const remainingDays = Math.floor(duration / DAY); + const remainingHours = Math.floor(duration / HOUR); + const remainingMinutes = Math.floor(duration / MINUTE); + + if (remainingDays > 1) { + remaining = i18n('message--giftBadge--remaining--days', { + days: remainingDays, + }); + } else if (remainingHours > 1) { + remaining = i18n('message--giftBadge--remaining--hours', { + hours: remainingHours, + }); + } else if (remainingMinutes > 1) { + remaining = i18n('message--giftBadge--remaining--minutes', { + minutes: remainingMinutes, + }); + } else if (remainingMinutes === 1) { + remaining = i18n('message--giftBadge--remaining--one-minute'); + } else { + remaining = i18n('message--giftBadge--expired'); + } + + const wasSent = direction === 'outgoing'; + const buttonContents = wasSent ? ( + i18n('message--giftBadge--view') + ) : ( + <> + {' '} + {i18n('message--giftBadge--redeemed')} + + ); + + const badgeElement = badge ? ( + {badge.name} + ) : ( +
+ ); + + return ( +
+
+ {badgeElement} +
+
+ {i18n('message--giftBadge')} +
+
+ {remaining} +
+
+
+ + {this.renderMetadata()} + {showOutgoingGiftBadgeModal ? ( + + this.setState({ showOutgoingGiftBadgeModal: false }) + } + /> + ) : null} +
+ ); + } + + throw missingCaseError(giftBadge.state); + } + public renderQuote(): JSX.Element | null { const { conversationColor, @@ -1216,14 +1460,13 @@ export class Message extends React.PureComponent { id, quote, scrollToQuotedMessage, - shouldCollapseAbove, } = this.props; if (!quote) { return null; } - const { isViewOnce, referencedMessageNotFound } = quote; + const { isGiftBadge, isViewOnce, referencedMessageNotFound } = quote; const clickHandler = disableScroll ? undefined @@ -1236,19 +1479,6 @@ export class Message extends React.PureComponent { const isIncoming = direction === 'incoming'; - let curveTopLeft: boolean; - let curveTopRight: boolean; - if (this.shouldRenderAuthor()) { - curveTopLeft = false; - curveTopRight = false; - } else if (isIncoming) { - curveTopLeft = !shouldCollapseAbove; - curveTopRight = true; - } else { - curveTopLeft = true; - curveTopRight = !shouldCollapseAbove; - } - return ( { bodyRanges={quote.bodyRanges} conversationColor={conversationColor} customColor={customColor} - curveTopLeft={curveTopLeft} - curveTopRight={curveTopRight} isViewOnce={isViewOnce} + isGiftBadge={isGiftBadge} referencedMessageNotFound={referencedMessageNotFound} isFromMe={quote.isFromMe} doubleCheckMissingQuoteReference={() => @@ -1279,7 +1508,6 @@ export class Message extends React.PureComponent { direction, i18n, storyReplyContext, - shouldCollapseAbove, } = this.props; if (!storyReplyContext) { @@ -1288,19 +1516,6 @@ export class Message extends React.PureComponent { const isIncoming = direction === 'incoming'; - let curveTopLeft: boolean; - let curveTopRight: boolean; - if (this.shouldRenderAuthor()) { - curveTopLeft = false; - curveTopRight = false; - } else if (isIncoming) { - curveTopLeft = !shouldCollapseAbove; - curveTopRight = true; - } else { - curveTopLeft = true; - curveTopRight = !shouldCollapseAbove; - } - return ( <> {storyReplyContext.emoji && ( @@ -1311,11 +1526,10 @@ export class Message extends React.PureComponent { { deleteMessage, deleteMessageForEveryone, deletedForEveryone, + giftBadge, i18n, id, isSticker, @@ -1769,7 +1984,8 @@ export class Message extends React.PureComponent { text, } = this.props; - const canForward = !isTapToView && !deletedForEveryone && !contact; + const canForward = + !isTapToView && !deletedForEveryone && !giftBadge && !contact; const multipleAttachments = attachments && attachments.length > 1; const shouldShowAdditional = @@ -1934,7 +2150,11 @@ export class Message extends React.PureComponent { } public getWidth(): number | undefined { - const { attachments, isSticker, previews } = this.props; + const { attachments, giftBadge, isSticker, previews } = this.props; + + if (giftBadge) { + return 240; + } if (attachments && attachments.length) { if (isGIF(attachments)) { @@ -2370,7 +2590,7 @@ export class Message extends React.PureComponent { } public renderContents(): JSX.Element | null { - const { isTapToView, deletedForEveryone } = this.props; + const { giftBadge, isTapToView, deletedForEveryone } = this.props; if (deletedForEveryone) { return ( @@ -2381,6 +2601,10 @@ export class Message extends React.PureComponent { ); } + if (giftBadge) { + return this.renderGiftBadge(); + } + if (isTapToView) { return ( <> @@ -2412,11 +2636,13 @@ export class Message extends React.PureComponent { contact, displayTapToViewMessage, direction, + giftBadge, id, isTapToView, isTapToViewExpired, kickOffAttachmentDownload, openConversation, + openGiftBadge, showContactDetail, showVisualAttachment, showExpiredIncomingTapToViewToast, @@ -2426,6 +2652,11 @@ export class Message extends React.PureComponent { const isAttachmentPending = this.isAttachmentPending(); + if (giftBadge && giftBadge.state === GiftBadgeStates.Unopened) { + openGiftBadge(id); + return; + } + if (isTapToView) { if (isAttachmentPending) { log.info( @@ -2621,6 +2852,7 @@ export class Message extends React.PureComponent { customColor, deletedForEveryone, direction, + giftBadge, isSticker, isTapToView, isTapToViewExpired, @@ -2632,7 +2864,7 @@ export class Message extends React.PureComponent { const isAttachmentPending = this.isAttachmentPending(); const width = this.getWidth(); - const isShowingImage = this.isShowingImage(); + const shouldUseWidth = Boolean(giftBadge || this.isShowingImage()); const isEmojiOnly = this.canRenderStickerLikeEmoji(); const isStickerLike = isSticker || isEmojiOnly; @@ -2673,7 +2905,7 @@ export class Message extends React.PureComponent { : null ); const containerStyles = { - width: isShowingImage ? width : undefined, + width: shouldUseWidth ? width : undefined, }; if (!isStickerLike && !deletedForEveryone && direction === 'outgoing') { Object.assign(containerStyles, getCustomColorStyle(customColor)); diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index 7d36f3e9c6ae..172a03055bc5 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -36,6 +36,7 @@ const defaultMessage: MessageDataPropsType = { canDownload: true, conversationColor: 'crimson', conversationId: 'my-convo', + conversationTitle: 'Conversation Title', conversationType: 'direct', direction: 'incoming', id: 'my-message', @@ -81,6 +82,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markViewed: action('markViewed'), openConversation: action('openConversation'), + openGiftBadge: action('openGiftBadge'), openLink: action('openLink'), reactToMessage: action('reactToMessage'), renderAudioAttachment: () =>
*AudioAttachment*
, diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index dfd881c29d0b..d0a0ff5f41d1 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -73,6 +73,7 @@ export type PropsBackboneActions = Pick< | 'markAttachmentAsCorrupted' | 'markViewed' | 'openConversation' + | 'openGiftBadge' | 'openLink' | 'reactToMessage' | 'renderAudioAttachment' @@ -284,6 +285,7 @@ export class MessageDetail extends React.Component { markAttachmentAsCorrupted, markViewed, openConversation, + openGiftBadge, openLink, reactToMessage, renderAudioAttachment, @@ -339,6 +341,7 @@ export class MessageDetail extends React.Component { markViewed={markViewed} messageExpanded={noop} openConversation={openConversation} + openGiftBadge={openGiftBadge} openLink={openLink} reactToMessage={reactToMessage} renderAudioAttachment={renderAudioAttachment} diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index a643793a488d..b037de3c651d 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -49,6 +49,7 @@ const defaultMessageProps: MessagesProps = { containerWidthBreakpoint: WidthBreakpoint.Wide, conversationColor: 'crimson', conversationId: 'conversationId', + conversationTitle: 'Conversation Title', conversationType: 'direct', // override deleteMessage: action('default--deleteMessage'), deleteMessageForEveryone: action('default--deleteMessageForEveryone'), @@ -70,6 +71,7 @@ const defaultMessageProps: MessagesProps = { markViewed: action('default--markViewed'), messageExpanded: action('default--message-expanded'), openConversation: action('default--openConversation'), + openGiftBadge: action('openGiftBadge'), openLink: action('default--openLink'), previews: [], reactToMessage: action('default--reactToMessage'), @@ -110,6 +112,7 @@ const renderInMessage = ({ isFromMe, rawAttachment, isViewOnce, + isGiftBadge, referencedMessageNotFound, text: quoteText, }: Props) => { @@ -123,6 +126,7 @@ const renderInMessage = ({ isFromMe, rawAttachment, isViewOnce, + isGiftBadge, referencedMessageNotFound, sentAt: Date.now() - 30 * 1000, text: quoteText, @@ -139,7 +143,10 @@ const renderInMessage = ({ }; const createProps = (overrideProps: Partial = {}): Props => ({ - authorTitle: text('authorTitle', overrideProps.authorTitle || ''), + authorTitle: text( + 'authorTitle', + overrideProps.authorTitle || 'Default Sender' + ), conversationColor: overrideProps.conversationColor || 'forest', doubleCheckMissingQuoteReference: overrideProps.doubleCheckMissingQuoteReference || @@ -154,6 +161,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ 'referencedMessageNotFound', overrideProps.referencedMessageNotFound || false ), + isGiftBadge: boolean('isGiftBadge', overrideProps.isGiftBadge || false), isViewOnce: boolean('isViewOnce', overrideProps.isViewOnce || false), text: text( 'text', @@ -338,6 +346,15 @@ story.add('Video Tap-to-View', () => { return ; }); +story.add('Gift Badge', () => { + const props = createProps({ + text: '', + isGiftBadge: true, + }); + + return renderInMessage(props); +}); + story.add('Audio Only', () => { const props = createProps({ rawAttachment: { diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 89e7e9e6ca85..c6567d8c2053 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -26,8 +26,6 @@ import { getCustomColorStyle } from '../../util/getCustomColorStyle'; export type Props = { authorTitle: string; conversationColor: ConversationColorType; - curveTopLeft?: boolean; - curveTopRight?: boolean; customColor?: CustomColorType; bodyRanges?: BodyRangesType; i18n: LocalizerType; @@ -39,6 +37,7 @@ export type Props = { onClose?: () => void; text: string; rawAttachment?: QuotedAttachmentType; + isGiftBadge: boolean; isViewOnce: boolean; reactionEmoji?: string; referencedMessageNotFound: boolean; @@ -62,6 +61,10 @@ function validateQuote(quote: Props): boolean { return true; } + if (quote.isGiftBadge) { + return true; + } + if (quote.text) { return true; } @@ -178,7 +181,12 @@ export class Quote extends React.Component { }); }; - public renderImage(url: string, icon?: string): JSX.Element { + public renderImage( + url: string, + icon: string | undefined, + isGiftBadge?: boolean + ): JSX.Element { + const { isIncoming } = this.props; const iconElement = icon ? (
{ return ( @@ -261,10 +274,14 @@ export class Quote extends React.Component { } public renderIconContainer(): JSX.Element | null { - const { rawAttachment, isViewOnce, i18n } = this.props; + const { isGiftBadge, isViewOnce, i18n, rawAttachment } = this.props; const { imageBroken } = this.state; const attachment = getAttachment(rawAttachment); + if (isGiftBadge) { + return this.renderImage('images/gift-thumbnail.svg', undefined, true); + } + if (!attachment) { return null; } @@ -295,7 +312,7 @@ export class Quote extends React.Component { } if (GoogleChrome.isImageTypeSupported(contentType)) { return url && !imageBroken - ? this.renderImage(url) + ? this.renderImage(url, undefined) : this.renderIcon('image'); } if (MIME.isAudio(contentType)) { @@ -306,8 +323,15 @@ export class Quote extends React.Component { } public renderText(): JSX.Element | null { - const { bodyRanges, i18n, text, rawAttachment, isIncoming, isViewOnce } = - this.props; + const { + bodyRanges, + isGiftBadge, + i18n, + text, + rawAttachment, + isIncoming, + isViewOnce, + } = this.props; if (text) { const quoteText = bodyRanges @@ -334,18 +358,22 @@ export class Quote extends React.Component { const attachment = getAttachment(rawAttachment); - if (!attachment) { + let typeLabel; + + if (isGiftBadge) { + typeLabel = i18n('quote--giftBadge'); + } else if (attachment) { + const { contentType, isVoiceMessage } = attachment; + typeLabel = getTypeLabel({ + i18n, + isViewOnce, + contentType, + isVoiceMessage, + }); + } else { return null; } - const { contentType, isVoiceMessage } = attachment; - - const typeLabel = getTypeLabel({ - i18n, - isViewOnce, - contentType, - isVoiceMessage, - }); if (typeLabel) { return (
{ public override render(): JSX.Element | null { const { conversationColor, - curveTopLeft, - curveTopRight, customColor, isIncoming, onClick, @@ -506,9 +532,7 @@ export class Quote extends React.Component { : this.getClassName(`--outgoing-${conversationColor}`), !onClick && this.getClassName('--no-click'), referencedMessageNotFound && - this.getClassName('--with-reference-warning'), - curveTopLeft && this.getClassName('--curve-top-left'), - curveTopRight && this.getClassName('--curve-top-right') + this.getClassName('--with-reference-warning') )} style={{ ...getCustomColorStyle(customColor, true) }} > diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 985f5771b5b4..a00c191cf612 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -55,6 +55,7 @@ const items: Record = { canRetryDeleteForEveryone: true, conversationColor: 'forest', conversationId: 'conversation-id', + conversationTitle: 'Conversation Title', conversationType: 'group', direction: 'incoming', id: 'id-1', @@ -80,6 +81,7 @@ const items: Record = { canRetryDeleteForEveryone: true, conversationColor: 'forest', conversationId: 'conversation-id', + conversationTitle: 'Conversation Title', conversationType: 'group', direction: 'incoming', id: 'id-2', @@ -119,6 +121,7 @@ const items: Record = { canRetryDeleteForEveryone: true, conversationColor: 'crimson', conversationId: 'conversation-id', + conversationTitle: 'Conversation Title', conversationType: 'group', direction: 'incoming', id: 'id-3', @@ -219,6 +222,7 @@ const items: Record = { canRetryDeleteForEveryone: true, conversationColor: 'plum', conversationId: 'conversation-id', + conversationTitle: 'Conversation Title', conversationType: 'group', direction: 'outgoing', id: 'id-6', @@ -245,6 +249,7 @@ const items: Record = { canRetryDeleteForEveryone: true, conversationColor: 'crimson', conversationId: 'conversation-id', + conversationTitle: 'Conversation Title', conversationType: 'group', direction: 'outgoing', id: 'id-7', @@ -271,6 +276,7 @@ const items: Record = { canRetryDeleteForEveryone: true, conversationColor: 'crimson', conversationId: 'conversation-id', + conversationTitle: 'Conversation Title', conversationType: 'group', direction: 'outgoing', id: 'id-8', @@ -297,6 +303,7 @@ const items: Record = { canRetryDeleteForEveryone: true, conversationColor: 'crimson', conversationId: 'conversation-id', + conversationTitle: 'Conversation Title', conversationType: 'group', direction: 'outgoing', id: 'id-9', @@ -323,6 +330,7 @@ const items: Record = { canRetryDeleteForEveryone: true, conversationColor: 'crimson', conversationId: 'conversation-id', + conversationTitle: 'Conversation Title', conversationType: 'group', direction: 'outgoing', id: 'id-10', @@ -379,6 +387,7 @@ const actions = () => ({ doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), openLink: action('openLink'), + openGiftBadge: action('openGiftBadge'), scrollToQuotedMessage: action('scrollToQuotedMessage'), showExpiredIncomingTapToViewToast: action( 'showExpiredIncomingTapToViewToast' diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index e2fe4cfff825..086c392f0447 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -248,6 +248,7 @@ const getActions = createSelector( 'deleteMessageForEveryone', 'showMessageDetail', 'openConversation', + 'openGiftBadge', 'showContactDetail', 'showContactModal', 'kickOffAttachmentDownload', diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index f1eb2444ab44..1553c083fec7 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -75,6 +75,7 @@ const getDefaultProps = () => ({ messageExpanded: action('messageExpanded'), showMessageDetail: action('showMessageDetail'), openConversation: action('openConversation'), + openGiftBadge: action('openGiftBadge'), showContactDetail: action('showContactDetail'), showContactModal: action('showContactModal'), showForwardMessageModal: action('showForwardMessageModal'), diff --git a/ts/messageModifiers/ViewSyncs.ts b/ts/messageModifiers/ViewSyncs.ts index 1c050375e2e7..3186066d8bd9 100644 --- a/ts/messageModifiers/ViewSyncs.ts +++ b/ts/messageModifiers/ViewSyncs.ts @@ -11,6 +11,7 @@ import { markViewed } from '../services/MessageUpdater'; import { isIncoming, isStory } from '../state/selectors/message'; import { notificationService } from '../services/notifications'; import * as log from '../logging/log'; +import { GiftBadgeStates } from '../components/conversation/Message'; export type ViewSyncAttributesType = { senderId: string; @@ -92,6 +93,16 @@ export class ViewSyncs extends Collection { message.set(markViewed(message.attributes, sync.get('viewedAt'))); } + const giftBadge = message.get('giftBadge'); + if (giftBadge) { + message.set({ + giftBadge: { + ...giftBadge, + state: GiftBadgeStates.Redeemed, + }, + }); + } + this.remove(sync); } catch (error) { log.error( diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index ed7658062485..5d21bffd0aa9 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -4,29 +4,20 @@ import * as Backbone from 'backbone'; import { GroupV2ChangeType } from './groups'; -import { LocalizerType, BodyRangeType, BodyRangesType } from './types/Util'; +import { BodyRangeType, BodyRangesType } from './types/Util'; import { CallHistoryDetailsFromDiskType } from './types/Calling'; import { CustomColorType } from './types/Colors'; import { DeviceType } from './textsecure/Types'; -import { SendOptionsType } from './textsecure/SendMessage'; import { SendMessageChallengeData } from './textsecure/Errors'; -import { UserMessage } from './types/Message'; import { MessageModel } from './models/messages'; import { ConversationModel } from './models/conversations'; import { ProfileNameChangeType } from './util/getStringForProfileChange'; import { CapabilitiesType } from './textsecure/WebAPI'; import { ReadStatus } from './messages/MessageReadStatus'; -import { - SendState, - SendStateByConversationId, -} from './messages/MessageSendState'; +import { SendStateByConversationId } from './messages/MessageSendState'; import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions'; import { ConversationColorType } from './types/Colors'; -import { - AttachmentDraftType, - AttachmentType, - ThumbnailType, -} from './types/Attachment'; +import { AttachmentDraftType, AttachmentType } from './types/Attachment'; import { EmbeddedContactType } from './types/EmbeddedContact'; import { SignalService as Proto } from './protobuf'; import { AvatarDataType } from './types/Avatar'; @@ -36,6 +27,7 @@ import { ReactionSource } from './reactions/ReactionSource'; import AccessRequiredEnum = Proto.AccessControl.AccessRequired; import MemberRoleEnum = Proto.Member.Role; import { SeenStatus } from './MessageSeenStatus'; +import { GiftBadgeStates } from './components/conversation/Message'; export type WhatIsThis = any; @@ -80,10 +72,11 @@ export type QuotedMessageType = { authorUuid?: string; bodyRanges?: BodyRangesType; id: number; - referencedMessageNotFound: boolean; + isGiftBadge?: boolean; isViewOnce: boolean; - text?: string; messageId: string; + referencedMessageNotFound: boolean; + text?: string; }; type StoryReplyContextType = { @@ -187,6 +180,12 @@ export type MessageAttributesType = { contact?: Array; conversationId: string; storyReactionEmoji?: string; + giftBadge?: { + expiration: number; + level: number; + receiptCredentialPresentation: string; + state: GiftBadgeStates; + }; expirationTimerUpdate?: { expireTimer: number; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index cf0f82f25e14..636acff33e1c 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -93,6 +93,7 @@ import { SignalService as Proto } from '../protobuf'; import { getMessagePropStatus, hasErrors, + isGiftBadge, isIncoming, isStory, isTapToView, @@ -1818,7 +1819,6 @@ export class ConversationModel extends window.Backbone const { customColor, customColorId } = this.getCustomColorData(); // TODO: DESKTOP-720 - /* eslint-disable @typescript-eslint/no-non-null-assertion */ return { id: this.id, uuid: this.get('uuid'), @@ -1832,6 +1832,7 @@ export class ConversationModel extends window.Backbone aboutText: this.get('about'), aboutEmoji: this.get('aboutEmoji'), acceptedMessageRequest: this.getAccepted(), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion activeAt: this.get('active_at')!, areWePending: Boolean( ourConversationId && this.isMemberPending(ourConversationId) @@ -1857,14 +1858,14 @@ export class ConversationModel extends window.Backbone draftPreview, draftText, familyName: this.get('profileFamilyName'), - firstName: this.get('profileName')!, + firstName: this.get('profileName'), groupDescription: this.get('description'), groupVersion, groupId: this.get('groupId'), groupLink: this.getGroupLink(), hideStory: Boolean(this.get('hideStory')), inboxPosition, - isArchived: this.get('isArchived')!, + isArchived: this.get('isArchived'), isBlocked: this.isBlocked(), isMe: isMe(this.attributes), isGroupV1AndDisabled: this.isGroupV1AndDisabled(), @@ -1873,9 +1874,10 @@ export class ConversationModel extends window.Backbone isVerified: this.isVerified(), isFetchingUUID: this.isFetchingUUID, lastMessage, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion lastUpdated: this.get('timestamp')!, left: Boolean(this.get('left')), - markedUnread: this.get('markedUnread')!, + markedUnread: this.get('markedUnread'), membersCount: this.getMembersCount(), memberships: this.getMemberships(), messageCount: this.get('messageCount') || 0, @@ -1891,23 +1893,23 @@ export class ConversationModel extends window.Backbone announcementsOnly: Boolean(this.get('announcementsOnly')), announcementsOnlyReady: this.canBeAnnouncementGroup(), expireTimer: this.get('expireTimer'), - muteExpiresAt: this.get('muteExpiresAt')!, + muteExpiresAt: this.get('muteExpiresAt'), dontNotifyForMentionsIfMuted: this.get('dontNotifyForMentionsIfMuted'), - name: this.get('name')!, - phoneNumber: this.getNumber()!, - profileName: this.getProfileName()!, + name: this.get('name'), + phoneNumber: this.getNumber(), + profileName: this.getProfileName(), profileSharing: this.get('profileSharing'), publicParams: this.get('publicParams'), secretParams: this.get('secretParams'), shouldShowDraft, sortedGroupMembers, timestamp, - title: this.getTitle()!, + title: this.getTitle(), typingContactId: typingMostRecent?.senderId, searchableTitle: isMe(this.attributes) ? window.i18n('noteToSelf') : this.getTitle(), - unreadCount: this.get('unreadCount')! || 0, + unreadCount: this.get('unreadCount') || 0, ...(isDirectConversation(this.attributes) ? { type: 'direct' as const, @@ -1920,7 +1922,6 @@ export class ConversationModel extends window.Backbone sharedGroupNames: [], }), }; - /* eslint-enable @typescript-eslint/no-non-null-assertion */ } updateE164(e164?: string | null): void { @@ -3762,6 +3763,7 @@ export class ConversationModel extends window.Backbone bodyRanges: quotedMessage.get('bodyRanges'), id: quotedMessage.get('sent_at'), isViewOnce: isTapToView(quotedMessage.attributes), + isGiftBadge: isGiftBadge(quotedMessage.attributes), messageId: quotedMessage.get('id'), referencedMessageNotFound: false, text: body || embeddedContactName, diff --git a/ts/models/messages.ts b/ts/models/messages.ts index c7fe758a7b4a..5774fa2af088 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -38,6 +38,7 @@ import type { } from '../textsecure/Types.d'; import { SendMessageProtoError } from '../textsecure/Errors'; import * as expirationTimer from '../util/expirationTimer'; +import { getUserLanguages } from '../util/userLanguages'; import type { ReactionType } from '../types/Reactions'; import { UUID, UUIDKind } from '../types/UUID'; @@ -86,6 +87,7 @@ import { isDeliveryIssue, isEndSession, isExpirationTimerUpdate, + isGiftBadge, isGroupUpdate, isGroupV1Migration, isGroupV2Change, @@ -153,6 +155,8 @@ import { shouldShowStoriesView } from '../state/selectors/stories'; import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage'; import { SeenStatus } from '../MessageSeenStatus'; import { isNewReactionReplacingPrevious } from '../reactions/util'; +import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer'; +import { GiftBadgeStates } from '../components/conversation/Message'; /* eslint-disable camelcase */ /* eslint-disable more/no-then */ @@ -762,6 +766,26 @@ export class MessageModel extends window.Backbone.Model { }; } + const giftBadge = this.get('giftBadge'); + if (giftBadge) { + const emoji = '🎁'; + + if (isIncoming(this.attributes)) { + return { + emoji, + text: window.i18n('message--giftBadge--preview--sent'), + }; + } + + return { + emoji, + text: + giftBadge.state === GiftBadgeStates.Unopened + ? window.i18n('message--giftBadge--preview--unopened') + : window.i18n('message--giftBadge--preview--redeemed'), + }; + } + if (body) { return { text: body }; } @@ -1093,6 +1117,7 @@ export class MessageModel extends window.Backbone.Model { const isCallHistoryValue = isCallHistory(attributes); const isChatSessionRefreshedValue = isChatSessionRefreshed(attributes); const isDeliveryIssueValue = isDeliveryIssue(attributes); + const isGiftBadgeValue = isGiftBadge(attributes); const isGroupUpdateValue = isGroupUpdate(attributes); const isGroupV2ChangeValue = isGroupV2Change(attributes); const isEndSessionValue = isEndSession(attributes); @@ -1124,6 +1149,7 @@ export class MessageModel extends window.Backbone.Model { isCallHistoryValue || isChatSessionRefreshedValue || isDeliveryIssueValue || + isGiftBadgeValue || isGroupUpdateValue || isGroupV2ChangeValue || isEndSessionValue || @@ -1812,6 +1838,7 @@ export class MessageModel extends window.Backbone.Model { // Just placeholder values for the fields referencedMessageNotFound: false, + isGiftBadge: quote.type === Proto.DataMessage.Quote.Type.GIFT_BADGE, isViewOnce: false, messageId: '', }; @@ -1869,6 +1896,23 @@ export class MessageModel extends window.Backbone.Model { return; } + const isMessageAGiftBadge = isGiftBadge(originalMessage.attributes); + if (isMessageAGiftBadge !== quote.isGiftBadge) { + log.warn( + `copyQuoteContentFromOriginal: Quote.isGiftBadge: ${quote.isGiftBadge}, isGiftBadge(message): ${isMessageAGiftBadge}` + ); + // eslint-disable-next-line no-param-reassign + quote.isGiftBadge = isMessageAGiftBadge; + } + if (isMessageAGiftBadge) { + // eslint-disable-next-line no-param-reassign + quote.text = undefined; + // eslint-disable-next-line no-param-reassign + quote.attachments = []; + + return; + } + // eslint-disable-next-line no-param-reassign quote.isViewOnce = false; @@ -2310,6 +2354,7 @@ export class MessageModel extends window.Backbone.Model { decrypted_at: now, errors: [], flags: dataMessage.flags, + giftBadge: initialMessage.giftBadge, hasAttachments: dataMessage.hasAttachments, hasFileAttachments: dataMessage.hasFileAttachments, hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, @@ -2612,9 +2657,50 @@ export class MessageModel extends window.Backbone.Model { conversation.incrementMessageCount(); window.Signal.Data.updateConversation(conversation.attributes); + const reduxState = window.reduxStore.getState(); + + const giftBadge = message.get('giftBadge'); + if (giftBadge) { + const { level } = giftBadge; + const existingBadgesById = reduxState.badges.byId; + + const badgeId = `BOOST-${level}`; + if (!existingBadgesById[badgeId]) { + const { updatesUrl } = window.SignalContext.config; + strictAssert( + typeof updatesUrl === 'string', + 'getProfile: expected updatesUrl to be a defined string' + ); + const userLanguages = getUserLanguages( + navigator.languages, + window.getLocale() + ); + const response = + await window.textsecure.messaging.server.getBoostBadgesFromServer( + userLanguages + ); + const boostBadges = parseBoostBadgeListFromServer( + response, + updatesUrl + ); + const badge = boostBadges[badgeId]; + if (!badge) { + log.error( + `handleDataMessage: gift badge ${badgeId} not found on server` + ); + } else { + await window.reduxActions.badges.updateOrCreate([ + { + ...badge, + id: badgeId, + }, + ]); + } + } + } + // Only queue attachments for downloads if this is a story or // outgoing message or we've accepted the conversation - const reduxState = window.reduxStore.getState(); const attachments = this.get('attachments') || []; let queueStoryForDownload = false; diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 84c022679d18..2469129554d2 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -502,6 +502,7 @@ export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)( authorUuid, id: sentAt, isViewOnce, + isGiftBadge: isTargetGiftBadge, referencedMessageNotFound, text = '', } = quote; @@ -534,6 +535,7 @@ export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)( rawAttachment: firstAttachment ? processQuoteAttachment(firstAttachment) : undefined, + isGiftBadge: Boolean(isTargetGiftBadge), isViewOnce, referencedMessageNotFound, sentAt: Number(sentAt), @@ -569,6 +571,7 @@ type ShallowPropsType = Pick< | 'contactNameColor' | 'conversationColor' | 'conversationId' + | 'conversationTitle' | 'conversationType' | 'customColor' | 'deletedForEveryone' @@ -576,6 +579,7 @@ type ShallowPropsType = Pick< | 'displayLimit' | 'expirationLength' | 'expirationTimestamp' + | 'giftBadge' | 'id' | 'isBlocked' | 'isMessageRequestAccepted' @@ -654,6 +658,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)( contactNameColor, conversationColor, conversationId, + conversationTitle: conversation.title, conversationType: isGroup ? 'group' : 'direct', customColor, deletedForEveryone: message.deletedForEveryone || false, @@ -661,6 +666,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)( displayLimit: message.displayLimit, expirationLength, expirationTimestamp, + giftBadge: message.giftBadge, id: message.id, isBlocked: conversation.isBlocked || false, isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true, @@ -1080,6 +1086,14 @@ function getPropsForVerificationNotification( }; } +// Gift Badge + +export function isGiftBadge( + message: Pick +): boolean { + return Boolean(message.giftBadge); +} + // Group Update (V1) export function isGroupUpdate( diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx index 1842dfc6a2cb..9f9ae4de50b3 100644 --- a/ts/state/smart/MessageDetail.tsx +++ b/ts/state/smart/MessageDetail.tsx @@ -45,6 +45,7 @@ const mapStateToProps = ( markAttachmentAsCorrupted, markViewed, openConversation, + openGiftBadge, openLink, reactToMessage, replyToMessage, @@ -89,6 +90,7 @@ const mapStateToProps = ( markAttachmentAsCorrupted, markViewed, openConversation, + openGiftBadge, openLink, reactToMessage, renderAudioAttachment, diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 85b193f33465..f42bb5605a1c 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -83,6 +83,7 @@ export type TimelinePropsType = ExternalProps & | 'onDelete' | 'onUnblock' | 'openConversation' + | 'openGiftBadge' | 'openLink' | 'reactToMessage' | 'removeMember' diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx index ef125b5e8e05..b7c3b66e11f8 100644 --- a/ts/state/smart/TimelineItem.tsx +++ b/ts/state/smart/TimelineItem.tsx @@ -102,8 +102,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { id: messageId, containerElementRef, conversationId, - conversationColor: conversation?.conversationColor, - customColor: conversation?.customColor, + conversationColor: conversation.conversationColor, + customColor: conversation.customColor, getPreferredBadge: getPreferredBadgeSelector(state), isNextItemCallingNotification, isSelected, diff --git a/ts/test-both/processDataMessage_test.ts b/ts/test-both/processDataMessage_test.ts index ba2599cf33f5..ee1fc471998e 100644 --- a/ts/test-both/processDataMessage_test.ts +++ b/ts/test-both/processDataMessage_test.ts @@ -200,6 +200,7 @@ describe('processDataMessage', () => { }, ], bodyRanges: [], + type: 0, }); }); diff --git a/ts/test-both/state/ducks/composer_test.ts b/ts/test-both/state/ducks/composer_test.ts index 1caaaf3f6ccc..3842410261c5 100644 --- a/ts/test-both/state/ducks/composer_test.ts +++ b/ts/test-both/state/ducks/composer_test.ts @@ -19,6 +19,7 @@ describe('both/state/ducks/composer', () => { attachments: [], id: 456, isViewOnce: false, + isGiftBadge: false, messageId: '789', referencedMessageNotFound: false, }, diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 4fb0f8e04ad2..12c3796f73a3 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -118,11 +118,12 @@ export type StickerType = { }; export type QuoteType = { - id?: number; - authorUuid?: string; - text?: string; attachments?: Array; + authorUuid?: string; bodyRanges?: BodyRangesType; + id?: number; + isGiftBadge?: boolean; + text?: string; }; export type ReactionType = { @@ -494,6 +495,12 @@ class Message { proto.quote = new Quote(); const { quote } = proto; + if (this.quote.isGiftBadge) { + quote.type = Proto.DataMessage.Quote.Type.GIFT_BADGE; + } else { + quote.type = Proto.DataMessage.Quote.Type.NORMAL; + } + quote.id = this.quote.id === undefined ? null : Long.fromNumber(this.quote.id); quote.authorUuid = this.quote.authorUuid || null; diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 620b58dd04eb..13a05c7fe54f 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -5,6 +5,7 @@ import type { SignalService as Proto } from '../protobuf'; import type { IncomingWebSocketRequest } from './WebsocketResources'; import type { UUID } from '../types/UUID'; import type { TextAttachmentType } from '../types/Attachment'; +import { GiftBadgeStates } from '../components/conversation/Message'; export { IdentityKeyType, @@ -143,6 +144,7 @@ export type ProcessedQuote = { text?: string; attachments: ReadonlyArray; bodyRanges: ReadonlyArray; + type: Proto.DataMessage.Quote.Type; }; export type ProcessedAvatar = { @@ -186,6 +188,13 @@ export type ProcessedGroupCallUpdate = Proto.DataMessage.IGroupCallUpdate; export type ProcessedStoryContext = Proto.DataMessage.IStoryContext; +export type ProcessedGiftBadge = { + receiptCredentialPresentation: string; + level: number; + expiration: number; + state: GiftBadgeStates; +}; + export type ProcessedDataMessage = { body?: string; attachments: ReadonlyArray; @@ -207,6 +216,7 @@ export type ProcessedDataMessage = { bodyRanges?: ReadonlyArray; groupCallUpdate?: ProcessedGroupCallUpdate; storyContext?: ProcessedStoryContext; + giftBadge?: ProcessedGiftBadge; }; export type ProcessedUnidentifiedDeliveryStatus = Omit< diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index b5724cea77dd..5c39122967dc 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -527,6 +527,7 @@ const URL_CALLS = { accountExistence: 'v1/accounts/account', attachmentId: 'v2/attachments/form/upload', attestation: 'v1/attestation', + boostBadges: 'v1/subscription/boost/badges', challenge: 'v1/challenge', config: 'v1/config', deliveryCert: 'v1/certificate/delivery', @@ -660,6 +661,7 @@ export type WebAPIConnectType = { export type CapabilitiesType = { announcementGroup: boolean; + giftBadges: boolean; 'gv1-migration': boolean; senderKey: boolean; changeNumber: boolean; @@ -667,6 +669,7 @@ export type CapabilitiesType = { }; export type CapabilitiesUploadType = { announcementGroup: true; + giftBadges: true; 'gv2-3': true; 'gv1-migration': true; senderKey: true; @@ -864,6 +867,9 @@ export type WebAPIType = { options: GetProfileUnauthOptionsType ) => Promise; getBadgeImageFile: (imageUrl: string) => Promise; + getBoostBadgesFromServer: ( + userLanguages: ReadonlyArray + ) => Promise; getProvisioningResource: ( handler: IRequestHandler ) => Promise; @@ -1186,6 +1192,7 @@ export function initialize({ getProfileForUsername, getProfileUnauth, getBadgeImageFile, + getBoostBadgesFromServer, getProvisioningResource, getSenderCertificate, getSticker, @@ -1630,6 +1637,19 @@ export function initialize({ }); } + async function getBoostBadgesFromServer( + userLanguages: ReadonlyArray + ): Promise { + return _ajax({ + call: 'boostBadges', + httpType: 'GET', + headers: { + 'Accept-Language': formatAcceptLanguageHeader(userLanguages), + }, + responseType: 'json', + }); + } + async function getAvatar(path: string) { // Using _outerAJAX, since it's not hardcoded to the Signal Server. Unlike our // attachment CDN, it uses our self-signed certificate, so we pass it in. @@ -1744,6 +1764,7 @@ export function initialize({ ) { const capabilities: CapabilitiesUploadType = { announcementGroup: true, + giftBadges: true, 'gv2-3': true, 'gv1-migration': true, senderKey: true, diff --git a/ts/textsecure/processDataMessage.ts b/ts/textsecure/processDataMessage.ts index d89b1dcd50d6..3f40fda2aded 100644 --- a/ts/textsecure/processDataMessage.ts +++ b/ts/textsecure/processDataMessage.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import Long from 'long'; +import { ReceiptCredentialPresentation } from '@signalapp/libsignal-client/zkgroup'; import { assert, strictAssert } from '../util/assert'; import { dropNull, shallowDropNull } from '../util/dropNull'; @@ -21,8 +22,10 @@ import type { ProcessedSticker, ProcessedReaction, ProcessedDelete, + ProcessedGiftBadge, } from './Types.d'; import { WarnOnlyError } from './Errors'; +import { GiftBadgeStates } from '../components/conversation/Message'; const FLAGS = Proto.DataMessage.Flags; export const ATTACHMENT_MAX = 32; @@ -130,6 +133,7 @@ export function processQuote( }; }), bodyRanges: quote.bodyRanges ?? [], + type: quote.type || Proto.DataMessage.Quote.Type.NORMAL, }; } @@ -227,6 +231,32 @@ export function processDelete( }; } +export function processGiftBadge( + timestamp: number, + giftBadge: Proto.DataMessage.IGiftBadge | null | undefined +): ProcessedGiftBadge | undefined { + if ( + !giftBadge || + !giftBadge.receiptCredentialPresentation || + giftBadge.receiptCredentialPresentation.length === 0 + ) { + return undefined; + } + + const receipt = new ReceiptCredentialPresentation( + Buffer.from(giftBadge.receiptCredentialPresentation) + ); + + return { + expiration: timestamp + Number(receipt.getReceiptExpirationTime()), + level: Number(receipt.getReceiptLevel()), + receiptCredentialPresentation: Bytes.toBase64( + giftBadge.receiptCredentialPresentation + ), + state: GiftBadgeStates.Unopened, + }; +} + export async function processDataMessage( message: Proto.IDataMessage, envelopeTimestamp: number @@ -276,6 +306,7 @@ export async function processDataMessage( bodyRanges: message.bodyRanges ?? [], groupCallUpdate: dropNull(message.groupCallUpdate), storyContext: dropNull(message.storyContext), + giftBadge: processGiftBadge(timestamp, message.giftBadge), }; const isEndSession = Boolean(result.flags & FLAGS.END_SESSION); diff --git a/ts/util/showToast.tsx b/ts/util/showToast.tsx index 71aeae3215e0..d65c2f8a986c 100644 --- a/ts/util/showToast.tsx +++ b/ts/util/showToast.tsx @@ -9,6 +9,10 @@ import type { ToastAlreadyRequestedToJoin } from '../components/ToastAlreadyRequ import type { ToastBlocked } from '../components/ToastBlocked'; import type { ToastBlockedGroup } from '../components/ToastBlockedGroup'; import type { ToastCannotMixImageAndNonImageAttachments } from '../components/ToastCannotMixImageAndNonImageAttachments'; +import type { + ToastCannotOpenGiftBadge, + ToastPropsType as ToastCannotOpenGiftBadgePropsType, +} from '../components/ToastCannotOpenGiftBadge'; import type { ToastCannotStartGroupCall } from '../components/ToastCannotStartGroupCall'; import type { ToastCaptchaFailed } from '../components/ToastCaptchaFailed'; import type { ToastCaptchaSolved } from '../components/ToastCaptchaSolved'; @@ -60,6 +64,10 @@ export function showToast( Toast: typeof ToastCannotMixImageAndNonImageAttachments ): void; export function showToast(Toast: typeof ToastCannotStartGroupCall): void; +export function showToast( + Toast: typeof ToastCannotOpenGiftBadge, + props: Omit +): void; export function showToast(Toast: typeof ToastCaptchaFailed): void; export function showToast(Toast: typeof ToastCaptchaSolved): void; export function showToast( diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index a5a9e5ab69cb..927532db5daa 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -97,6 +97,7 @@ import { ToastReportedSpamAndBlocked } from '../components/ToastReportedSpamAndB import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpiredIncoming'; import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing'; import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment'; +import { ToastCannotOpenGiftBadge } from '../components/ToastCannotOpenGiftBadge'; import { autoScale } from '../util/handleImageAttachment'; import { copyGroupLink } from '../util/copyGroupLink'; import { deleteDraftAttachment } from '../util/deleteDraftAttachment'; @@ -163,6 +164,7 @@ type MessageActionsType = { markAttachmentAsCorrupted: (options: AttachmentOptions) => unknown; markViewed: (messageId: string) => unknown; openConversation: (conversationId: string, messageId?: string) => unknown; + openGiftBadge: (messageId: string) => unknown; openLink: (url: string) => unknown; reactToMessage: ( messageId: string, @@ -859,6 +861,17 @@ export class ConversationView extends window.Backbone.View { const showIdentity = (conversationId: string) => { this.showSafetyNumber(conversationId); }; + const openGiftBadge = (messageId: string): void => { + const message = window.MessageController.getById(messageId); + if (!message) { + throw new Error(`openGiftBadge: Message ${messageId} missing!`); + } + + showToast(ToastCannotOpenGiftBadge, { + isIncoming: isIncoming(message.attributes), + }); + }; + const openLink = openLinkInWebBrowser; const downloadNewVersion = () => { openLinkInWebBrowser('https://signal.org/download'); @@ -888,6 +901,7 @@ export class ConversationView extends window.Backbone.View { markAttachmentAsCorrupted, markViewed: onMarkViewed, openConversation, + openGiftBadge, openLink, reactToMessage, replyToMessage,