Support for incoming gift badges

This commit is contained in:
Scott Nonnenberg 2022-05-11 13:59:58 -07:00 committed by GitHub
parent 6b4bea6330
commit 0ba6a0926e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1476 additions and 164 deletions

View File

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

16
images/gift-bow.svg Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Export" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 60">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: #2c6bed;
}
</style>
</defs>
<path class="cls-2" d="M78.738,36.229l-6.797-4.513c2.831-.994,5.188-2.288,6.311-3.947,.864-1.277,1.032-2.749,.473-4.147-3.227-8.067-7.676-15.12-13.223-20.963l-.889-.936-1.219,.424c-9.233,3.215-17.112,12.09-20.719,16.685-.825-.229-1.757-.292-2.677-.292s-1.852,.063-2.678,.291c-3.607-4.595-11.485-13.469-20.717-16.683l-1.219-.424-.889,.936C8.95,8.502,4.501,15.555,1.274,23.622c-.559,1.398-.391,2.871,.473,4.147,1.123,1.659,3.48,2.953,6.311,3.947l-6.797,4.513-1.052,.698,.178,1.25,2.573,18.069,.485,3.406,2.72-2.107c.786-.609,19.3-14.953,22.677-17.79,.925-.777,4.435-3.789,7.171-6.997,.981,.326,2.269,.482,3.986,.482s3.005-.156,3.986-.482c2.736,3.208,6.246,6.22,7.171,6.997,3.377,2.837,21.89,17.18,22.677,17.79l2.72,2.107,.485-3.406,2.573-18.069,.178-1.25-1.052-.698Z"/>
<path class="cls-1" d="M54.872,34.542c2.815,0,9.139-.572,14.602-2.063l8.158,5.417-2.573,18.069s-19.23-14.896-22.615-17.74c-1.637-1.375-3.599-3.174-5.349-5.013,1.802,.578,3.962,1.099,6.284,1.28,.433,.034,.935,.051,1.493,.051ZM4.941,55.964s19.23-14.896,22.615-17.74c1.637-1.375,3.599-3.174,5.349-5.013-1.802,.578-3.962,1.099-6.284,1.28-.433,.034-.935,.051-1.493,.051-2.815,0-9.139-.572-14.602-2.063l-8.158,5.417,2.573,18.069ZM43.241,21.846c-.097-.513-.154-1.306-3.241-1.306s-3.144,.793-3.241,1.306l-1.227,7.615c-.123,.649-.002,1.779,4.468,1.779s4.591-1.13,4.468-1.779l-1.227-7.615Zm33.628,2.519c-2.298-5.745-6.187-13.346-12.816-20.329-8.561,2.981-16.064,11.33-19.62,15.81,.369,.409,.646,.933,.774,1.629l.005,.026,.004,.027,1.223,7.594c.123,.685,.025,1.246-.156,1.693,1.893,.709,4.464,1.464,7.252,1.682,.384,.03,.833,.045,1.337,.045,6.927,0,24.139-2.821,21.997-8.176Zm-43.308,4.756l1.223-7.594,.012-.065,.004-.022c.125-.683,.398-1.199,.762-1.601-3.558-4.481-11.058-12.824-19.615-15.803C9.318,11.019,5.429,18.62,3.131,24.365c-2.142,5.355,15.07,8.176,21.997,8.176,.504,0,.953-.015,1.337-.045,2.788-.218,5.359-.972,7.252-1.682-.181-.447-.28-1.008-.156-1.693Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

19
images/gift-thumbnail.svg Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 54">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: #2c6bed;
}
</style>
</defs>
<rect class="cls-2" width="54" height="54"/>
<rect class="cls-1" y="24" width="54" height="6"/>
<rect class="cls-1" x="24" width="6" height="54"/>
<path class="cls-2" d="M42.511,31.491l-2.634-1.805c1.097-.398,2.01-.915,2.445-1.579,.335-.511,.4-1.1,.183-1.659-1.25-3.227-2.974-6.048-5.124-8.385l-.344-.374-.472,.17c-3.578,1.286-6.631,4.836-8.029,6.674-.32-.092-.681-.117-1.037-.117s-.718,.025-1.038,.116c-1.398-1.838-4.45-5.388-8.028-6.673l-.472-.17-.344,.374c-2.149,2.337-3.873,5.158-5.123,8.385-.217,.559-.151,1.148,.183,1.659,.435,.664,1.349,1.181,2.446,1.579l-2.634,1.805-.408,.279,.069,.5,.997,7.228,.188,1.362,1.054-.843c.305-.244,7.479-5.981,8.787-7.116,.358-.311,1.719-1.516,2.779-2.799,.38,.13,.879,.193,1.545,.193s1.164-.062,1.545-.193c1.06,1.283,2.42,2.488,2.779,2.799,1.309,1.135,8.482,6.872,8.787,7.116l1.054,.843,.188-1.362,.997-7.228,.069-.5-.408-.279h0Z"/>
<path class="cls-1" d="M33.263,30.817c1.091,0,3.541-.229,5.658-.825l3.161,2.167-.997,7.228s-7.452-5.958-8.763-7.096c-.634-.55-1.395-1.27-2.073-2.005,.698,.231,1.535,.44,2.435,.512,.168,.014,.362,.02,.579,.02h0Zm-19.348,8.568s7.452-5.958,8.763-7.096c.634-.55,1.395-1.27,2.073-2.005-.698,.231-1.535,.44-2.435,.512-.168,.014-.362,.02-.579,.02-1.091,0-3.541-.229-5.658-.825l-3.161,2.167,.997,7.228h0Zm14.841-13.648c-.038-.205-.06-.522-1.256-.522s-1.218,.317-1.256,.522l-.476,3.046c-.048,.26,0,.712,1.731,.712s1.779-.452,1.731-.712l-.475-3.046h0Zm13.031,1.008c-.89-2.298-2.397-5.338-4.966-8.132-3.317,1.192-6.225,4.532-7.603,6.324,.143,.164,.25,.373,.3,.652l.002,.01,.002,.011,.474,3.038c.048,.274,.01,.498-.061,.677,.734,.284,1.73,.586,2.81,.673,.149,.012,.323,.018,.518,.018,2.684,0,9.354-1.128,8.524-3.27h0Zm-16.782,1.902l.474-3.038,.005-.026,.002-.009c.048-.273,.154-.48,.295-.64-1.379-1.792-4.285-5.13-7.601-6.321-2.569,2.793-4.076,5.834-4.966,8.132-.83,2.142,5.84,3.27,8.524,3.27,.195,0,.369-.006,.518-.018,1.08-.087,2.077-.389,2.81-.673-.07-.179-.109-.403-.06-.677h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2096,6 +2096,7 @@ export async function startApp(): Promise<void> {
await Promise.all([
server.registerCapabilities({
announcementGroup: true,
giftBadges: true,
'gv2-3': true,
'gv1-migration': true,
senderKey: true,

View File

@ -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<string, BadgeType> {
const result: Record<string, BadgeType> = {};
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;

View File

@ -191,6 +191,7 @@ story.add('Quote', () => (
quotedMessageProps: {
text: 'something',
conversationColor: ConversationColors[10],
isGiftBadge: false,
isViewOnce: false,
referencedMessageNotFound: false,
authorTitle: 'Someone',

View File

@ -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> = {}): 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 <OutgoingGiftBadgeModal {...createProps()} />;
});
story.add('Missing badge', () => {
const props = {
...createProps(),
getPreferredBadge: () => undefined,
};
return <OutgoingGiftBadgeModal {...props} />;
});

View File

@ -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 ? (
<img
className={`${CLASS_NAME}__badge`}
src={badgeImagePath}
alt={badge.name}
/>
) : (
<div
className={classNames(
`${CLASS_NAME}__badge`,
`${CLASS_NAME}__badge--missing`
)}
aria-label={i18n('giftBadge--missing')}
/>
);
return (
<Modal
i18n={i18n}
moduleClassName={`${CLASS_NAME}__container`}
onClose={hideOutgoingGiftBadgeModal}
hasXButton
useFocusTrap
>
<div className={CLASS_NAME}>
<div className={`${CLASS_NAME}__title`}>
{i18n('modal--giftBadge--title')}
</div>
<div className={`${CLASS_NAME}__description`}>
{i18n('modal--giftBadge--description', { name: recipientTitle })}
</div>
{badgeElement}
<div className={`${CLASS_NAME}__badge-summary`}>
{i18n('message--giftBadge')}
</div>
</div>
</Modal>
);
};

View File

@ -143,6 +143,7 @@ export const StoryViewsNRepliesModal = ({
conversationColor="ultramarine"
i18n={i18n}
isFromMe={false}
isGiftBadge={false}
isStoryReply
isViewOnce={false}
moduleClassName="StoryViewsNRepliesModal__quote"

View File

@ -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 <Toast onClose={onClose}>{i18n(key)}</Toast>;
};

View File

@ -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> = {}): 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> = {}): 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> = {}): 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);
});

View File

@ -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<HTMLDivElement>) => void;
@ -116,6 +122,7 @@ const SENT_STATUSES = new Set<MessageStatusType>([
'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<AttachmentType>;
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<Props, State> {
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<Props, State> {
reactionViewerRoot: null,
reactionPickerRoot: null,
giftBadgeCounter: null,
showOutgoingGiftBadgeModal: false,
hasDeleteForEveryoneTimerExpired:
this.getTimeRemainingForDeleteForEveryone() <= 0,
};
@ -490,6 +519,7 @@ export class Message extends React.PureComponent<Props, State> {
this.startSelectedTimer();
this.startDeleteForEveryoneTimerIfApplicable();
this.startGiftBadgeInterval();
const { isSelected } = this.props;
if (isSelected) {
@ -519,6 +549,7 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
deletedForEveryone,
expirationLength,
expirationTimestamp,
giftBadge,
i18n,
shouldHideMetadata,
status,
text,
@ -576,6 +609,17 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
}
}
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<Props, State> {
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<Props, State> {
);
}
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 (
<div className="module-message__unopened-gift-badge__container">
<div
className={classNames(
'module-message__unopened-gift-badge',
`module-message__unopened-gift-badge--${direction}`
)}
aria-label={i18n('message--giftBadge--unopened--label')}
>
<div
className="module-message__unopened-gift-badge__ribbon-horizontal"
aria-hidden
/>
<div
className="module-message__unopened-gift-badge__ribbon-vertical"
aria-hidden
/>
<img
className="module-message__unopened-gift-badge__bow"
src="images/gift-bow.svg"
alt=""
aria-hidden
/>
</div>
<div
className={classNames(
'module-message__unopened-gift-badge__text',
`module-message__unopened-gift-badge__text--${direction}`
)}
>
<div
className={classNames(
'module-message__text',
`module-message__text--${direction}`
)}
dir={isRTL ? 'rtl' : undefined}
>
{description}
{this.getMetadataPlacement() ===
MetadataPlacement.InlineWithText && (
<MessageTextMetadataSpacer metadataWidth={metadataWidth} />
)}
</div>
{this.renderMetadata()}
</div>
</div>
);
}
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')
) : (
<>
<span
className={classNames(
'module-message__redeemed-gift-badge__icon-check',
`module-message__redeemed-gift-badge__icon-check--${direction}`
)}
/>{' '}
{i18n('message--giftBadge--redeemed')}
</>
);
const badgeElement = badge ? (
<img
className="module-message__redeemed-gift-badge__badge"
src={badgeImagePath}
alt={badge.name}
/>
) : (
<div
className={classNames(
'module-message__redeemed-gift-badge__badge',
`module-message__redeemed-gift-badge__badge--missing-${direction}`
)}
aria-label={i18n('giftBadge--missing')}
/>
);
return (
<div className="module-message__redeemed-gift-badge__container">
<div className="module-message__redeemed-gift-badge">
{badgeElement}
<div className="module-message__redeemed-gift-badge__text">
<div className="module-message__redeemed-gift-badge__title">
{i18n('message--giftBadge')}
</div>
<div
className={classNames(
'module-message__redeemed-gift-badge__remaining',
`module-message__redeemed-gift-badge__remaining--${direction}`
)}
>
{remaining}
</div>
</div>
</div>
<button
className={classNames(
'module-message__redeemed-gift-badge__button',
`module-message__redeemed-gift-badge__button--${direction}`
)}
disabled={!wasSent}
onClick={
wasSent
? () => this.setState({ showOutgoingGiftBadgeModal: true })
: undefined
}
type="button"
>
<div className="module-message__redeemed-gift-badge__button__text">
{buttonContents}
</div>
</button>
{this.renderMetadata()}
{showOutgoingGiftBadgeModal ? (
<OutgoingGiftBadgeModal
i18n={i18n}
recipientTitle={conversationTitle}
badgeId={badgeId}
getPreferredBadge={getPreferredBadge}
hideOutgoingGiftBadgeModal={() =>
this.setState({ showOutgoingGiftBadgeModal: false })
}
/>
) : null}
</div>
);
}
throw missingCaseError(giftBadge.state);
}
public renderQuote(): JSX.Element | null {
const {
conversationColor,
@ -1216,14 +1460,13 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
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 (
<Quote
i18n={i18n}
@ -1260,9 +1490,8 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
direction,
i18n,
storyReplyContext,
shouldCollapseAbove,
} = this.props;
if (!storyReplyContext) {
@ -1288,19 +1516,6 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
<Quote
authorTitle={storyReplyContext.authorTitle}
conversationColor={conversationColor}
curveTopLeft={curveTopLeft}
curveTopRight={curveTopRight}
customColor={customColor}
i18n={i18n}
isFromMe={storyReplyContext.isFromMe}
isGiftBadge={false}
isIncoming={isIncoming}
isStoryReply
isViewOnce={false}
@ -1757,6 +1971,7 @@ export class Message extends React.PureComponent<Props, State> {
deleteMessage,
deleteMessageForEveryone,
deletedForEveryone,
giftBadge,
i18n,
id,
isSticker,
@ -1769,7 +1984,8 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
}
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<Props, State> {
}
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<Props, State> {
);
}
if (giftBadge) {
return this.renderGiftBadge();
}
if (isTapToView) {
return (
<>
@ -2412,11 +2636,13 @@ export class Message extends React.PureComponent<Props, State> {
contact,
displayTapToViewMessage,
direction,
giftBadge,
id,
isTapToView,
isTapToViewExpired,
kickOffAttachmentDownload,
openConversation,
openGiftBadge,
showContactDetail,
showVisualAttachment,
showExpiredIncomingTapToViewToast,
@ -2426,6 +2652,11 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
customColor,
deletedForEveryone,
direction,
giftBadge,
isSticker,
isTapToView,
isTapToViewExpired,
@ -2632,7 +2864,7 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
: null
);
const containerStyles = {
width: isShowingImage ? width : undefined,
width: shouldUseWidth ? width : undefined,
};
if (!isStickerLike && !deletedForEveryone && direction === 'outgoing') {
Object.assign(containerStyles, getCustomColorStyle(customColor));

View File

@ -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> = {}): Props => ({
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
markViewed: action('markViewed'),
openConversation: action('openConversation'),
openGiftBadge: action('openGiftBadge'),
openLink: action('openLink'),
reactToMessage: action('reactToMessage'),
renderAudioAttachment: () => <div>*AudioAttachment*</div>,

View File

@ -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<Props> {
markAttachmentAsCorrupted,
markViewed,
openConversation,
openGiftBadge,
openLink,
reactToMessage,
renderAudioAttachment,
@ -339,6 +341,7 @@ export class MessageDetail extends React.Component<Props> {
markViewed={markViewed}
messageExpanded={noop}
openConversation={openConversation}
openGiftBadge={openGiftBadge}
openLink={openLink}
reactToMessage={reactToMessage}
renderAudioAttachment={renderAudioAttachment}

View File

@ -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> = {}): 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> = {}): 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 <Quote {...props} />;
});
story.add('Gift Badge', () => {
const props = createProps({
text: '',
isGiftBadge: true,
});
return renderInMessage(props);
});
story.add('Audio Only', () => {
const props = createProps({
rawAttachment: {

View File

@ -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<Props, State> {
});
};
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 ? (
<div className={this.getClassName('__icon-container__inner')}>
<div
@ -196,7 +204,12 @@ export class Quote extends React.Component<Props, State> {
return (
<ThumbnailImage
className={this.getClassName('__icon-container')}
className={classNames(
this.getClassName('__icon-container'),
isIncoming === false &&
isGiftBadge &&
this.getClassName('__icon-container__outgoing-gift-badge')
)}
src={url}
onError={this.handleImageError}
>
@ -261,10 +274,14 @@ export class Quote extends React.Component<Props, State> {
}
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<Props, State> {
}
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<Props, State> {
}
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<Props, State> {
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 (
<div
@ -476,8 +504,6 @@ export class Quote extends React.Component<Props, State> {
public override render(): JSX.Element | null {
const {
conversationColor,
curveTopLeft,
curveTopRight,
customColor,
isIncoming,
onClick,
@ -506,9 +532,7 @@ export class Quote extends React.Component<Props, State> {
: 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) }}
>

View File

@ -55,6 +55,7 @@ const items: Record<string, TimelineItemType> = {
canRetryDeleteForEveryone: true,
conversationColor: 'forest',
conversationId: 'conversation-id',
conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'incoming',
id: 'id-1',
@ -80,6 +81,7 @@ const items: Record<string, TimelineItemType> = {
canRetryDeleteForEveryone: true,
conversationColor: 'forest',
conversationId: 'conversation-id',
conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'incoming',
id: 'id-2',
@ -119,6 +121,7 @@ const items: Record<string, TimelineItemType> = {
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'incoming',
id: 'id-3',
@ -219,6 +222,7 @@ const items: Record<string, TimelineItemType> = {
canRetryDeleteForEveryone: true,
conversationColor: 'plum',
conversationId: 'conversation-id',
conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'outgoing',
id: 'id-6',
@ -245,6 +249,7 @@ const items: Record<string, TimelineItemType> = {
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'outgoing',
id: 'id-7',
@ -271,6 +276,7 @@ const items: Record<string, TimelineItemType> = {
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'outgoing',
id: 'id-8',
@ -297,6 +303,7 @@ const items: Record<string, TimelineItemType> = {
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'outgoing',
id: 'id-9',
@ -323,6 +330,7 @@ const items: Record<string, TimelineItemType> = {
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'

View File

@ -248,6 +248,7 @@ const getActions = createSelector(
'deleteMessageForEveryone',
'showMessageDetail',
'openConversation',
'openGiftBadge',
'showContactDetail',
'showContactModal',
'kickOffAttachmentDownload',

View File

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

View File

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

27
ts/model-types.d.ts vendored
View File

@ -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<EmbeddedContactType>;
conversationId: string;
storyReactionEmoji?: string;
giftBadge?: {
expiration: number;
level: number;
receiptCredentialPresentation: string;
state: GiftBadgeStates;
};
expirationTimerUpdate?: {
expireTimer: number;

View File

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

View File

@ -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<MessageAttributesType> {
};
}
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<MessageAttributesType> {
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<MessageAttributesType> {
isCallHistoryValue ||
isChatSessionRefreshedValue ||
isDeliveryIssueValue ||
isGiftBadgeValue ||
isGroupUpdateValue ||
isGroupV2ChangeValue ||
isEndSessionValue ||
@ -1812,6 +1838,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// 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<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
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;

View File

@ -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<MessageWithUIFieldsType, 'giftBadge'>
): boolean {
return Boolean(message.giftBadge);
}
// Group Update (V1)
export function isGroupUpdate(

View File

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

View File

@ -83,6 +83,7 @@ export type TimelinePropsType = ExternalProps &
| 'onDelete'
| 'onUnblock'
| 'openConversation'
| 'openGiftBadge'
| 'openLink'
| 'reactToMessage'
| 'removeMember'

View File

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

View File

@ -200,6 +200,7 @@ describe('processDataMessage', () => {
},
],
bodyRanges: [],
type: 0,
});
});

View File

@ -19,6 +19,7 @@ describe('both/state/ducks/composer', () => {
attachments: [],
id: 456,
isViewOnce: false,
isGiftBadge: false,
messageId: '789',
referencedMessageNotFound: false,
},

View File

@ -118,11 +118,12 @@ export type StickerType = {
};
export type QuoteType = {
id?: number;
authorUuid?: string;
text?: string;
attachments?: Array<AttachmentType>;
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;

View File

@ -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<ProcessedQuoteAttachment>;
bodyRanges: ReadonlyArray<Proto.DataMessage.IBodyRange>;
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<ProcessedAttachment>;
@ -207,6 +216,7 @@ export type ProcessedDataMessage = {
bodyRanges?: ReadonlyArray<ProcessedBodyRange>;
groupCallUpdate?: ProcessedGroupCallUpdate;
storyContext?: ProcessedStoryContext;
giftBadge?: ProcessedGiftBadge;
};
export type ProcessedUnidentifiedDeliveryStatus = Omit<

View File

@ -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<ProfileType>;
getBadgeImageFile: (imageUrl: string) => Promise<Uint8Array>;
getBoostBadgesFromServer: (
userLanguages: ReadonlyArray<string>
) => Promise<unknown>;
getProvisioningResource: (
handler: IRequestHandler
) => Promise<WebSocketResource>;
@ -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<string>
): Promise<unknown> {
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,

View File

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

View File

@ -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<ToastCannotOpenGiftBadgePropsType, 'i18n' | 'onClose'>
): void;
export function showToast(Toast: typeof ToastCaptchaFailed): void;
export function showToast(Toast: typeof ToastCaptchaSolved): void;
export function showToast(

View File

@ -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<ConversationModel> {
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<ConversationModel> {
markAttachmentAsCorrupted,
markViewed: onMarkViewed,
openConversation,
openGiftBadge,
openLink,
reactToMessage,
replyToMessage,