Support for incoming gift badges
This commit is contained in:
parent
6b4bea6330
commit
0ba6a0926e
41 changed files with 1476 additions and 164 deletions
|
@ -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
16
images/gift-bow.svg
Normal 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
19
images/gift-thumbnail.svg
Normal 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 |
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
46
stylesheets/components/OutgoingGiftBadgeModal.scss
Normal file
46
stylesheets/components/OutgoingGiftBadgeModal.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -191,6 +191,7 @@ story.add('Quote', () => (
|
|||
quotedMessageProps: {
|
||||
text: 'something',
|
||||
conversationColor: ConversationColors[10],
|
||||
isGiftBadge: false,
|
||||
isViewOnce: false,
|
||||
referencedMessageNotFound: false,
|
||||
authorTitle: 'Someone',
|
||||
|
|
57
ts/components/OutgoingGiftBadgeModal.stories.tsx
Normal file
57
ts/components/OutgoingGiftBadgeModal.stories.tsx
Normal 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} />;
|
||||
});
|
77
ts/components/OutgoingGiftBadgeModal.tsx
Normal file
77
ts/components/OutgoingGiftBadgeModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -143,6 +143,7 @@ export const StoryViewsNRepliesModal = ({
|
|||
conversationColor="ultramarine"
|
||||
i18n={i18n}
|
||||
isFromMe={false}
|
||||
isGiftBadge={false}
|
||||
isStoryReply
|
||||
isViewOnce={false}
|
||||
moduleClassName="StoryViewsNRepliesModal__quote"
|
||||
|
|
24
ts/components/ToastCannotOpenGiftBadge.tsx
Normal file
24
ts/components/ToastCannotOpenGiftBadge.tsx
Normal 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>;
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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) }}
|
||||
>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -248,6 +248,7 @@ const getActions = createSelector(
|
|||
'deleteMessageForEveryone',
|
||||
'showMessageDetail',
|
||||
'openConversation',
|
||||
'openGiftBadge',
|
||||
'showContactDetail',
|
||||
'showContactModal',
|
||||
'kickOffAttachmentDownload',
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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
27
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -83,6 +83,7 @@ export type TimelinePropsType = ExternalProps &
|
|||
| 'onDelete'
|
||||
| 'onUnblock'
|
||||
| 'openConversation'
|
||||
| 'openGiftBadge'
|
||||
| 'openLink'
|
||||
| 'reactToMessage'
|
||||
| 'removeMember'
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -200,6 +200,7 @@ describe('processDataMessage', () => {
|
|||
},
|
||||
],
|
||||
bodyRanges: [],
|
||||
type: 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ describe('both/state/ducks/composer', () => {
|
|||
attachments: [],
|
||||
id: 456,
|
||||
isViewOnce: false,
|
||||
isGiftBadge: false,
|
||||
messageId: '789',
|
||||
referencedMessageNotFound: false,
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
10
ts/textsecure/Types.d.ts
vendored
10
ts/textsecure/Types.d.ts
vendored
|
@ -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<
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue