Process incoming story messages

This commit is contained in:
Josh Perez 2022-03-04 16:14:52 -05:00 committed by GitHub
parent df7cdfacc7
commit eb91eb6fec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 4382 additions and 652 deletions

View file

@ -1087,6 +1087,9 @@
"accept": {
"message": "Accept"
},
"forward": {
"message": "Forward"
},
"done": {
"message": "Done",
"description": "Label for done"
@ -2323,6 +2326,10 @@
"message": "New conversation",
"description": "Label for header when starting a new conversation"
},
"stories": {
"message": "Stories",
"description": "Label for header to go to stories view"
},
"contactSearchPlaceholder": {
"message": "Search by name or phone number",
"description": "Placeholder to use when searching for contacts in the composer"
@ -6759,6 +6766,150 @@
"message": "Crop",
"description": "Performs the crop"
},
"MyStories__title": {
"message": "My Stories",
"description": "Title for the my stories list"
},
"MyStories__story": {
"message": "Your story",
"description": "aria-label for each one of your stories"
},
"MyStories__download": {
"message": "Download story",
"description": "aria-label for the download button"
},
"MyStories__more": {
"message": "More options",
"description": "aria-label for the more button"
},
"MyStories__views--singular": {
"message": "$num$ view",
"description": "Number of views your story has",
"placeholders": {
"num": {
"content": "$1",
"example": "1"
}
}
},
"MyStories__views--plural": {
"message": "$num$ views",
"description": "Number of views your story has",
"placeholders": {
"num": {
"content": "$1",
"example": "16"
}
}
},
"MyStories__replies--singular": {
"message": "$num$ reply",
"description": "Number of replies your story has",
"placeholders": {
"num": {
"content": "$1",
"example": "1"
}
}
},
"MyStories__replies--plural": {
"message": "$num$ replies",
"description": "Number of replies your story has",
"placeholders": {
"num": {
"content": "$1",
"example": "3"
}
}
},
"MyStories__delete": {
"message": "Delete this story? It will also be deleted for everyone who received it.",
"description": "Confirmation dialog description text for deleting a story"
},
"Stories__title": {
"message": "Stories",
"description": "Title for the stories list"
},
"Stories__mine": {
"message": "My Stories",
"description": "Label for your stories"
},
"Stories__add": {
"message": "Add a story",
"description": "Description hint to add a story"
},
"Stories__list-empty": {
"message": "No recent stories to show right now",
"description": "Description for when there are no stories to show"
},
"Stories__placeholder--text": {
"message": "Click to view a story",
"description": "Placeholder label for the story view"
},
"Stories__from-to-group": {
"message": "$name$ to $group$",
"description": "Title for someone sending a story to a group",
"placeholders": {
"name": {
"content": "$1",
"example": "Elle"
},
"group": {
"content": "$2",
"example": "Family"
}
}
},
"StoryViewer__reply": {
"message": "Reply",
"description": "Button label to reply to a story"
},
"StoryViewsNRepliesModal__placeholder": {
"message": "Type a reply...",
"description": "Placeholder text for the story reply modal"
},
"StoryViewsNRepliesModal__tab--views": {
"message": "Views",
"description": "Title for views tab"
},
"StoryViewsNRepliesModal__tab--replies": {
"message": "Replies",
"description": "Title for replies tab"
},
"StoryViewsNRepliesModal__react": {
"message": "React to story",
"description": "aria-label for reaction button"
},
"StoryViewsNRepliesModal__reacted": {
"message": "Reacted to the story",
"description": "Description of someone reacting to a story"
},
"StoryListItem__label": {
"message": "Story",
"description": "aria-label for the story list button"
},
"StoryListItem__hide": {
"message": "Hide story",
"description": "Label for menu item to hide the story"
},
"StoryListItem__go-to-chat": {
"message": "Go to chat",
"description": "Label for menu item to go to conversation"
},
"StoryListItem__hide-modal--body": {
"message": "Hide story? New story updates from $name$ wont appear at the top of the stories list anymore.",
"description": "Body for the confirmation dialog for hiding a story"
},
"StoryListItem__hide-modal--confirm": {
"message": "Hide",
"description": "Action button for the confirmation dialog to hide a story",
"placeholders": {
"name": {
"content": "$1",
"example": "Abby"
}
}
},
"WhatsNew__modal-title": {
"message": "What's New",
"description": "Title for the whats new modal"

View file

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m14.5833 2.91669c.884 0 1.7319.35119 2.357.97631s.9763 1.47297.9763 2.35702c0 1.21216-.4347 2.49132-1.1189 3.74998h1.4124c.5885-1.22874.9565-2.49772.9565-3.74998 0-1.21557-.4829-2.38136-1.3424-3.2409-.8596-.85954-2.0253-1.34243-3.2409-1.34243-.6024-.00071-1.199.11768-1.7555.34836-.5564.23069-1.0618.56911-1.487.99581-.4922.47395-.9409.99102-1.34085 1.545-.39995-.55398-.84868-1.07105-1.34084-1.545-.6408-.64144-1.45745-1.0784-2.34664-1.25562s-1.81096-.08673-2.64871.26002-1.55384.93418-2.05767 1.68799c-.50384.7538-.772779 1.6401-.772808 2.54677 0 4.96718 5.789968 10.19758 8.166708 12.13538v-1.6205c-2.83573-2.4499-6.91671-6.7921-6.91671-10.51488-.00003-.66004.1959-1.30523.56295-1.85381.36706-.54857.88871-.97582 1.49884-1.2276.61013-.25179 1.28128-.31676 1.92838-.1867.64711.13006 1.24103.44931 1.7065.91728.43217.41704.82779.87036 1.1825 1.355l1.0325 1.52583 1.03995-1.5225c.357-.48763.7551-.94375 1.19-1.36333.309-.31075.6765-.55715 1.0813-.72494.4048-.16778.8389-.25363 1.2771-.25256zm1.6667 10.08331h1.5v4h4.25v1.5h-4.25v4.5h-1.5v-4.5h-4.25v-1.5h4.25z" fill="#000" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg fill="none" height="20" viewBox="0 0 20 20" width="20" xmlns="http://www.w3.org/2000/svg"><g fill="#000"><path d="m1.5 8.99999c0-3.314 3.13333-5.99999 7-5.99999 2.8471 0 5.3406 1.49367 6.4246 3.62757-.4599-.08363-.9364-.12757-1.4246-.12757-3.818 0-7 2.75665-7 6.1578 0 .7718.16133 1.5103.45595 2.1911-.29877-.0589-.59436-.1348-.88528-.2276l-3.03867 1.0127c-.34733.116-.58267-.0854-.52267-.4467l.426-2.5573c-.91081-.9909-1.42217-2.2842-1.43533-3.63001z"/><path d="m13.5 8.33337c2.7619 0 5 1.90843 5 4.26313-.0094.9562-.3747 1.8751-1.0252 2.5792l.3042 1.817c.0429.2567-.1252.3998-.3733.3173l-2.1705-.7195c-.561.178-1.1463.2687-1.7352.2691-2.7619 0-5-1.9085-5-4.2631 0-2.3547 2.2729-4.26313 5-4.26313z"/></g></svg>

After

Width:  |  Height:  |  Size: 716 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>open-24</title><path d="M20,11.5V19a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V7A3,3,0,0,1,5,4h7.5V5.5H5A1.5,1.5,0,0,0,3.5,7V19A1.5,1.5,0,0,0,5,20.5H17A1.5,1.5,0,0,0,18.5,19V11.5ZM14,2V3.5h4.518l1.106-.184L8.97,13.97l1.06,1.06L20.684,4.376,20.5,5.482V10H22V2Z"/></svg>

After

Width:  |  Height:  |  Size: 345 B

View file

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m7 4c0-1.65685 1.34315-3 3-3h8c1.6569 0 3 1.34315 3 3v16c0 1.6569-1.3431 3-3 3h-8c-1.65685 0-3-1.3431-3-3zm2 0c0-.55228.44772-1 1-1h8c.5523 0 1 .44772 1 1v16c0 .5523-.4477 1-1 1h-8c-.55228 0-1-.4477-1-1zm-4-1c-1.10457 0-2 .89543-2 2v14c0 1.1046.89543 2 2 2z" fill="#000" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 422 B

View file

@ -0,0 +1 @@
<svg fill="none" height="56" viewBox="0 0 56 56" width="56" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd" fill="#000" fill-rule="evenodd"><path d="m11 3c-3.31371 0-6 2.68629-6 6v40c0 3.3137 2.68629 6 6 6h23c3.3137 0 6-2.6863 6-6v-40c0-3.31371-2.6863-6-6-6zm0 3c-1.65685 0-3 1.34314-3 3v40c0 1.6569 1.34315 3 3 3h23c1.6569 0 3-1.3431 3-3v-40c0-1.65685-1.3431-3-3-3z"/><path d="m40 44.7865 7.2953-27.2264c.8576-3.2008-1.0419-6.4908-4.2427-7.3484l-3.0526-.818v3.1058l2.2762.6099c1.6004.4289 2.5501 2.0739 2.1213 3.6743l-4.3975 16.4117z"/><path d="m40.1594 41.2569 9.5792-11.8292c2.0853-2.5753 1.6882-6.3535-.887-8.4388l-2.4168-1.9571-1.2632 2.8373 1.792 1.4512c1.2877 1.0427 1.4862 2.9318.4435 4.2194l-7.1655 8.8487z"/></g></svg>

After

Width:  |  Height:  |  Size: 738 B

View file

@ -320,7 +320,37 @@ message TypingMessage {
message StoryMessage {
optional bytes profileKey = 1;
optional GroupContextV2 group = 2;
optional AttachmentPointer attachment = 3;
oneof attachment {
AttachmentPointer fileAttachment = 3;
TextAttachment textAttachment = 4;
}
}
message TextAttachment {
enum Style {
DEFAULT = 0;
REGULAR = 1;
BOLD = 2;
SERIF = 3;
SCRIPT = 4;
CONDENSED = 5;
}
message Gradient {
optional uint32 startColor = 1;
optional uint32 endColor = 2;
optional uint32 angle = 3; // degrees
}
optional string text = 1;
optional Style textStyle = 2;
optional uint32 textForegroundColor = 3; // integer representation of hex color
optional uint32 textBackgroundColor = 4;
optional Preview preview = 5;
oneof background {
Gradient gradient = 6;
uint32 color = 7;
}
}
message Verified {

View file

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice;
@ -78,6 +78,7 @@ message ContactRecord {
optional bool archived = 11;
optional bool markedUnread = 12;
optional uint64 mutedUntilTimestamp = 13;
optional bool hideStory = 14;
}
message GroupV1Record {
@ -97,6 +98,7 @@ message GroupV2Record {
optional bool markedUnread = 5;
optional uint64 mutedUntilTimestamp = 6;
optional bool dontNotifyForMentionsIfMuted = 7;
optional bool hideStory = 8;
}
message AccountRecord {

View file

@ -2703,13 +2703,17 @@ button.ConversationDetails__action-button {
}
}
&__icon-container {
display: flex;
}
&__compose-icon {
-webkit-app-region: no-drag;
align-items: center;
background: none;
border-radius: 4px;
border: 2px solid transparent;
display: flex;
display: inline-flex;
height: 32px;
justify-content: center;
padding: 2px;
@ -2769,6 +2773,74 @@ button.ConversationDetails__action-button {
}
}
}
&__stories-icon {
-webkit-app-region: no-drag;
align-items: center;
background: none;
border-radius: 4px;
border: 2px solid transparent;
display: inline-flex;
height: 32px;
justify-content: center;
padding: 2px;
width: 32px;
margin-right: 8px;
@include light-theme {
&:hover,
&:focus {
background: $color-gray-15;
}
&:active {
background: $color-gray-05;
}
}
@include dark-theme {
&:hover,
&:focus {
background: $color-gray-75;
}
&:active {
background: $color-gray-65;
}
}
@include keyboard-mode {
&:focus {
border-color: $color-ultramarine;
}
}
@include dark-keyboard-mode {
&:focus {
border-color: $color-ultramarine-light;
}
}
&::before {
$icon: '../images/icons/v2/stories-outline-24.svg';
width: 24px;
height: 24px;
content: '';
@include light-theme {
@include color-svg($icon, $color-gray-75);
&:hover,
&:active,
&:focus {
@include color-svg($icon, $color-gray-90);
}
}
@include dark-theme {
@include color-svg($icon, $color-gray-15);
&:hover,
&:active,
&:focus {
@include color-svg($icon, $color-gray-02);
}
}
}
}
}
// Module: Image
@ -7747,7 +7819,7 @@ button.module-image__border-overlay:focus {
z-index: $z-index-popup-overlay;
}
.module-modal-host__container {
.module-modal-host__overlay-container {
display: flex;
flex-direction: column;
height: 100vh;

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-Avatar {
@ -138,4 +138,14 @@
}
}
}
&--with-story {
border-radius: 100%;
border: 2px solid $color-white-alpha-40;
padding: 3px;
&--unread {
border-color: $color-ultramarine-dawn;
}
}
}

View file

@ -0,0 +1,111 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.MyStories {
&__distribution {
padding: 0 14px;
&__title {
@include font-body-1-bold;
margin: 24px 0 8px 0;
}
}
&__story {
align-items: center;
display: flex;
height: 96px;
&__details {
@include font-body-1-bold;
display: flex;
flex-direction: column;
flex: 1;
}
&__preview {
@include button-reset;
align-items: center;
background-color: $color-gray-60;
background-size: cover;
border-radius: 8px;
height: 72px;
margin-right: 12px;
overflow: hidden;
width: 46px;
}
&__timestamp {
color: $color-gray-25;
font-weight: normal;
}
&__download {
@include button-reset;
align-items: center;
background: $color-gray-65;
border-radius: 100%;
display: flex;
height: 28px;
justify-content: center;
width: 28px;
&::after {
@include color-svg(
'../images/icons/v2/save-outline-24.svg',
$color-gray-25
);
content: '';
height: 18px;
width: 18px;
}
}
&__more {
align-items: center;
background: $color-gray-65;
border-radius: 100%;
display: flex;
height: 28px;
justify-content: center;
margin-left: 16px;
opacity: 1;
width: 28px;
&::after {
@include color-svg(
'../images/icons/v2/more-horiz-24.svg',
$color-gray-25
);
content: '';
height: 18px;
width: 18px;
}
}
}
&__icon {
&--save {
@include color-svg(
'../images/icons/v2/save-outline-24.svg',
$color-white
);
}
&--forward {
@include color-svg(
'../images/icons/v2/reply-outline-24.svg',
$color-white
);
transform: scaleX(-1);
}
&--delete {
@include color-svg(
'../images/icons/v2/trash-outline-24.svg',
$color-white
);
}
}
}

View file

@ -0,0 +1,134 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.Stories {
background: $color-gray-95;
display: flex;
height: 100vh;
left: 0;
position: absolute;
top: 0;
user-select: none;
width: 100%;
z-index: $z-index-popup-overlay;
&__pane {
background: $color-gray-80;
border-right: 1px solid $color-gray-65;
display: flex;
flex-direction: column;
height: 100%;
width: 380px;
padding-top: 42px;
&__header {
align-items: center;
display: flex;
justify-content: space-between;
padding: 0 16px;
&--centered {
justify-content: flex-start;
}
&--title {
@include font-body-1-bold;
display: flex;
flex: 1;
justify-content: center;
}
&--centered .Stories__pane__header--title {
text-align: center;
width: 100%;
}
&--camera {
@include button-reset;
@include color-svg(
'../images/icons/v2/camera-outline-24.svg',
$color-white
);
height: 22px;
width: 22px;
}
&--back {
@include button-reset;
height: 24px;
width: 24px;
@include light-theme {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$color-gray-60
);
}
@include keyboard-mode {
&:focus {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$color-ultramarine
);
}
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$color-gray-25
);
}
@include dark-keyboard-mode {
&:hover {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$color-ultramarine-light
);
}
}
}
}
&__list {
@include scrollbar;
flex: 1;
overflow-y: overlay;
&--empty {
@include font-body-1;
align-items: center;
color: $color-gray-45;
display: flex;
flex-direction: column;
justify-content: center;
}
}
}
&__search__container {
margin: 14px 16px 8px 16px;
}
&__placeholder {
align-items: center;
color: $color-gray-05;
display: flex;
flex-direction: column;
flex: 1;
justify-content: center;
&__stories {
height: 56px;
margin-bottom: 22px;
width: 56px;
@include color-svg(
'../images/icons/v2/stories-outline-56.svg',
$color-gray-05
);
}
}
}

View file

@ -0,0 +1,106 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.StoryListItem {
@include button-reset;
align-items: center;
border-radius: 10px;
display: flex;
padding: 0 20px;
height: 96px;
width: 100%;
&:hover {
background: $color-gray-65;
}
&__info {
display: flex;
flex: 1;
flex-direction: column;
margin-left: 12px;
&--title {
@include font-body-1-bold;
}
&--timestamp {
@include font-body-2;
color: $color-gray-25;
}
&--replies {
&--others {
@include color-svg(
'../images/icons/v2/messages-solid-20.svg',
$color-gray-25
);
height: 20px;
width: 20px;
}
&--self {
@include color-svg(
'../images/icons/v2/reply-solid-24.svg',
$color-gray-25
);
height: 20px;
width: 20px;
}
}
}
&__previews {
height: 72px;
position: relative;
width: 46px;
&--add {
&::after {
content: '';
@include color-svg('../images/icons/v2/plus-20.svg', $color-gray-15);
height: 18px;
width: 18px;
}
}
&--image {
@include button-reset;
align-items: center;
background-size: cover;
background-color: $color-gray-60;
border-radius: 8px;
display: flex;
height: 72px;
justify-content: center;
overflow: hidden;
position: absolute;
width: 46px;
z-index: $z-index-base;
}
&--multiple &--image {
border: 1px solid $color-gray-80;
}
&--more {
background: #99a8a0;
border-radius: 6px;
height: 62px;
position: absolute;
transform: rotate(-12deg);
width: 40px;
}
}
&__icon {
&--chat {
@include color-svg('../images/icons/v2/open-24.svg', $color-white);
}
&--hide {
@include color-svg('../images/icons/v2/x-24.svg', $color-white);
}
}
}

View file

@ -0,0 +1,113 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.StoryViewer {
&__overlay {
background: $color-gray-95;
filter: blur(160px);
height: 100vh;
left: 0;
position: absolute;
top: 0;
width: 100%;
z-index: $z-index-popup-overlay;
}
&__content {
align-items: center;
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
left: 0;
position: absolute;
top: 0;
width: 100%;
z-index: $z-index-popup-overlay;
}
&__close-button {
@include button-reset;
@include modal-close-button;
}
&__more {
@include button-reset;
height: 24px;
position: absolute;
right: 48px;
top: 12px;
width: 24px;
@include color-svg('../images/icons/v2/more-horiz-24.svg', $color-white);
}
&__container {
flex-grow: 1;
margin-top: 36px;
overflow: hidden;
position: relative;
z-index: $z-index-base;
}
&__story {
border-radius: 12px;
max-height: 100%;
outline: none;
width: auto;
}
&__meta {
bottom: 0;
left: 50%;
padding: 16px;
position: absolute;
transform: translateX(-50%);
width: 284px;
&--group-avatar {
margin-left: -8px;
}
&--title {
@include font-body-1-bold;
color: $color-white;
display: inline;
margin: 0 8px;
}
&--timestamp {
@include font-body-2;
color: $color-white-alpha-60;
}
}
&__actions {
margin: 16px 0 32px 0;
}
&__reply {
@include button-reset;
}
&__progress {
display: flex;
&--container {
background: $color-white-alpha-40;
border-radius: 2px;
height: 2px;
margin: 12px 1px 0 1px;
overflow: hidden;
width: 100%;
}
&--bar {
background: $color-white;
background-size: 200% 100%;
border-radius: 2px;
display: block;
height: 100%;
}
}
}

View file

@ -0,0 +1,152 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.StoryViewsNRepliesModal {
min-width: 320px;
&--group {
min-height: 360px;
}
&__overlay-container {
align-items: flex-end;
justify-content: flex-end;
}
.module-quote-container {
margin: 0;
margin-bottom: 8px;
}
&__compose-container {
display: flex;
align-items: center;
}
&__composer {
flex: 1;
margin-right: 16px;
}
&__emoji-button {
height: 24px;
margin-right: 8px;
width: 24px;
&::after {
@include dark-theme {
@include color-svg(
'../images/icons/v2/emoji-smiley-outline-24.svg',
$color-white
);
}
}
}
&__input {
&__input {
// For specificity because StoryViewsNRepliesModal is always in dark-theme
@include dark-theme {
background: $color-gray-75;
border: 1px solid $color-gray-75;
color: $color-white;
}
.ql-editor.ql-blank::before {
color: $color-gray-25;
}
&--with-children {
align-items: center;
display: flex;
}
.quill {
flex: 1;
}
}
}
&__react {
@include button-reset;
@include color-svg(
'../images/icons/v2/add-reaction-outline-24.svg',
$color-white
);
height: 22px;
width: 22px;
}
&__view {
align-items: center;
display: flex;
justify-content: space-between;
margin: 8px 0;
&--name {
@include font-body-2;
margin-left: 12px;
}
&--timestamp {
@include font-body-2;
color: $color-gray-45;
}
}
&__reply {
align-items: flex-end;
display: flex;
padding-bottom: 12px;
&--title {
@include font-body-2;
}
&--timestamp {
@include font-subtitle;
color: $color-gray-25;
margin-left: 6px;
}
}
&__reaction {
align-items: center;
display: flex;
justify-content: space-between;
padding: 12px 0;
&--container {
display: flex;
}
&--body {
margin-left: 20px;
}
}
&__message-bubble {
background: $color-gray-75;
border-radius: 18px;
margin-left: 8px;
padding: 7px 12px;
}
}
.Tabs.StoryViewsNRepliesModal__tabs {
border-bottom: none;
justify-content: center;
margin-bottom: 16px;
}
.Tabs__tab.StoryViewsNRepliesModal__tabs__tab {
@include font-body-1-bold;
padding: 4px 12px;
margin: 0 12px;
}
.Tabs__tab--selected.StoryViewsNRepliesModal__tabs__tab--selected {
background: $color-gray-65;
border-radius: 24px;
border-bottom: none;
}

View file

@ -86,6 +86,7 @@
@import './components/MessageBody.scss';
@import './components/MessageDetail.scss';
@import './components/Modal.scss';
@import './components/MyStories.scss';
@import './components/PermissionsPopup.scss';
@import './components/Preferences.scss';
@import './components/ProfileEditor.scss';
@ -97,6 +98,10 @@
@import './components/SearchResultsLoadingFakeRow.scss';
@import './components/Select.scss';
@import './components/Slider.scss';
@import './components/Stories.scss';
@import './components/StoryListItem.scss';
@import './components/StoryViewsNRepliesModal.scss';
@import './components/StoryViewer.scss';
@import './components/SystemMessage.scss';
@import './components/Tabs.scss';
@import './components/TimelineDateHeader.scss';

View file

@ -24,6 +24,7 @@ export type ConfigKeyType =
| 'desktop.sendSenderKey3'
| 'desktop.showUserBadges.beta'
| 'desktop.showUserBadges2'
| 'desktop.stories'
| 'desktop.usernames'
| 'global.calling.maxGroupCallRingSize'
| 'global.groupsv2.groupSizeHardLimit'

View file

@ -38,6 +38,7 @@ import { normalizeUuid } from './util/normalizeUuid';
import { filter } from './util/iterables';
import { isNotNil } from './util/isNotNil';
import { IdleDetector } from './IdleDetector';
import { loadStories, getStoriesForRedux } from './services/storyLoader';
import { senderCertificateService } from './services/senderCertificate';
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
import * as KeyboardLayout from './services/keyboardLayout';
@ -860,6 +861,7 @@ export async function startApp(): Promise<void> {
Stickers.load(),
loadRecentEmojis(),
loadInitialBadgesState(),
loadStories(),
window.textsecure.storage.protocol.hydrateCaches(),
]);
await window.ConversationController.checkForConflicts();
@ -890,7 +892,10 @@ export async function startApp(): Promise<void> {
function initializeRedux() {
// Here we set up a full redux store with initial state for our LeftPane Root
const convoCollection = window.getConversations();
const initialState = getInitialState({ badges: initialBadgesState });
const initialState = getInitialState({
badges: initialBadgesState,
stories: getStoriesForRedux(),
});
const store = window.Signal.State.createStore(initialState);
window.reduxStore = store;
@ -937,6 +942,7 @@ export async function startApp(): Promise<void> {
),
search: bindActionCreators(actionCreators.search, store.dispatch),
stickers: bindActionCreators(actionCreators.stickers, store.dispatch),
stories: bindActionCreators(actionCreators.stories, store.dispatch),
updates: bindActionCreators(actionCreators.updates, store.dispatch),
user: bindActionCreators(actionCreators.user, store.dispatch),
};
@ -2063,6 +2069,7 @@ export async function startApp(): Promise<void> {
'gv1-migration': true,
senderKey: true,
changeNumber: true,
stories: true,
}),
updateOurUsername(),
]);
@ -3268,7 +3275,7 @@ export async function startApp(): Promise<void> {
received_at_ms: data.receivedAtDate,
conversationId: descriptor.id,
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
type: 'incoming',
type: data.message.isStory ? 'story' : 'incoming',
readStatus: ReadStatus.Unread,
timestamp: data.timestamp,
} as Partial<MessageAttributesType> as WhatIsThis);

View file

@ -16,15 +16,17 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
type PropsType = {
appView: AppViewType;
openInbox: () => void;
registerSingleDevice: (number: string, code: string) => Promise<void>;
renderCallManager: () => JSX.Element;
renderGlobalModalContainer: () => JSX.Element;
openInbox: () => void;
isShowingStoriesView: boolean;
renderStories: () => JSX.Element;
requestVerification: (
type: 'sms' | 'voice',
number: string,
token: string
) => Promise<void>;
registerSingleDevice: (number: string, code: string) => Promise<void>;
theme: ThemeType;
} & ComponentProps<typeof Inbox>;
@ -36,11 +38,13 @@ export const App = ({
getPreferredBadge,
i18n,
isCustomizingPreferredReactions,
isShowingStoriesView,
renderCallManager,
renderCustomizingPreferredReactionsModal,
renderGlobalModalContainer,
renderSafetyNumber,
openInbox,
renderStories,
requestVerification,
registerSingleDevice,
theme,
@ -118,6 +122,7 @@ export const App = ({
>
{renderGlobalModalContainer()}
{renderCallManager()}
{isShowingStoriesView && renderStories()}
{contents}
</div>
);

View file

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -9,7 +9,7 @@ import { boolean, select, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import type { Props } from './Avatar';
import { Avatar, AvatarBlur } from './Avatar';
import { Avatar, AvatarBlur, AvatarStoryRing } from './Avatar';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import type { AvatarColorType } from '../types/Colors';
@ -236,3 +236,23 @@ story.add('Blurred with "click to view"', () => {
return <Avatar {...props} size={112} />;
});
story.add('Story: unread', () => (
<Avatar
{...createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
})}
storyRing={AvatarStoryRing.Unread}
size={112}
/>
));
story.add('Story: read', () => (
<Avatar
{...createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
})}
storyRing={AvatarStoryRing.Read}
size={112}
/>
));

View file

@ -45,6 +45,11 @@ export enum AvatarSize {
ONE_HUNDRED_TWELVE = 112,
}
export enum AvatarStoryRing {
Unread = 'Unread',
Read = 'Read',
}
type BadgePlacementType = { bottom: number; right: number };
export type Props = {
@ -65,6 +70,7 @@ export type Props = {
title: string;
unblurredAvatarPath?: string;
searchResult?: boolean;
storyRing?: AvatarStoryRing;
onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
onClickBadge?: (event: MouseEvent<HTMLButtonElement>) => unknown;
@ -118,6 +124,7 @@ export const Avatar: FunctionComponent<Props> = ({
title,
unblurredAvatarPath,
searchResult,
storyRing,
blur = getDefaultBlur({
acceptedMessageRequest,
avatarPath,
@ -301,6 +308,9 @@ export const Avatar: FunctionComponent<Props> = ({
className={classNames(
'module-Avatar',
hasImage ? 'module-Avatar--with-image' : 'module-Avatar--no-image',
storyRing && 'module-Avatar--with-story',
storyRing === AvatarStoryRing.Unread &&
'module-Avatar--with-story--unread',
className
)}
style={{

View file

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MutableRefObject } from 'react';
@ -623,7 +623,7 @@ export const CompositionArea = ({
// This one is for redux...
setQuotedMessage(undefined);
// and this is for conversation_view.
clearQuotedMessage();
clearQuotedMessage?.();
}}
/>
</div>

View file

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -61,6 +61,7 @@ export type InputApi = {
};
export type Props = {
children?: React.ReactNode;
readonly i18n: LocalizerType;
readonly disabled?: boolean;
readonly getPreferredBadge: PreferredBadgeSelectorType;
@ -71,6 +72,7 @@ export type Props = {
readonly draftBodyRanges?: Array<BodyRangeType>;
readonly moduleClassName?: string;
readonly theme: ThemeType;
readonly placeholder?: string;
sortedGroupMembers?: Array<ConversationType>;
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(
@ -85,8 +87,8 @@ export type Props = {
mentions: Array<BodyRangeType>,
timestamp: number
): unknown;
getQuotedMessage(): unknown;
clearQuotedMessage(): unknown;
getQuotedMessage?(): unknown;
clearQuotedMessage?(): unknown;
};
const MAX_LENGTH = 64 * 1024;
@ -94,6 +96,7 @@ const BASE_CLASS_NAME = 'module-composition-input';
export function CompositionInput(props: Props): React.ReactElement {
const {
children,
i18n,
disabled,
large,
@ -101,6 +104,7 @@ export function CompositionInput(props: Props): React.ReactElement {
moduleClassName,
onPickEmoji,
onSubmit,
placeholder,
skinTone,
draftText,
draftBodyRanges,
@ -341,8 +345,8 @@ export function CompositionInput(props: Props): React.ReactElement {
}
}
if (getQuotedMessage()) {
clearQuotedMessage();
if (getQuotedMessage?.()) {
clearQuotedMessage?.();
return false;
}
@ -561,7 +565,7 @@ export function CompositionInput(props: Props): React.ReactElement {
},
}}
formats={['emoji', 'mention']}
placeholder={i18n('sendMessage')}
placeholder={placeholder || i18n('sendMessage')}
readOnly={disabled}
ref={element => {
if (element) {
@ -635,9 +639,11 @@ export function CompositionInput(props: Props): React.ReactElement {
onClick={focus}
className={classNames(
getClassName('__input__scroller'),
large ? getClassName('__input__scroller--large') : null
large ? getClassName('__input__scroller--large') : null,
children ? getClassName('__input--with-children') : null
)}
>
{children}
{reactQuill}
{emojiCompletionElement}
{mentionCompletionElement}

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -19,21 +19,20 @@ const getDefaultProps = (): PropsType<number> => ({
menuOptions: [
{
label: '1',
value: 1,
onClick: action('1'),
},
{
label: '2',
value: 2,
onClick: action('2'),
},
{
label: '3',
value: 3,
onClick: action('3'),
},
],
onChange: action('onChange'),
value: 1,
});
// TODO DESKTOP-3184
story.add('Default', () => {
return <ContextMenu {...getDefaultProps()} />;
});

View file

@ -1,8 +1,10 @@
// Copyright 2018-2021 Signal Messenger, LLC
// Copyright 2018-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEvent } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import type { Options } from '@popperjs/core';
import FocusTrap from 'focus-trap-react';
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import { usePopper } from 'react-popper';
import { noop } from 'lodash';
@ -12,27 +14,128 @@ import type { LocalizerType } from '../types/Util';
import { themeClassName } from '../util/theme';
type OptionType<T> = {
readonly description?: string;
readonly icon?: string;
readonly label: string;
readonly description?: string;
readonly value: T;
readonly onClick: (value?: T) => unknown;
readonly value?: T;
};
export type ContextMenuPropsType<T> = {
readonly focusedIndex?: number;
readonly isMenuShowing: boolean;
readonly menuOptions: ReadonlyArray<OptionType<T>>;
readonly onClose: () => unknown;
readonly popperOptions?: Pick<Options, 'placement' | 'strategy'>;
readonly referenceElement: HTMLElement | null;
readonly theme?: Theme;
readonly title?: string;
readonly value?: T;
};
export type PropsType<T> = {
readonly buttonClassName?: string;
readonly i18n: LocalizerType;
readonly menuOptions: ReadonlyArray<OptionType<T>>;
readonly onChange: (value: T) => unknown;
readonly theme?: Theme;
readonly title?: string;
readonly value: T;
};
} & Pick<
ContextMenuPropsType<T>,
'menuOptions' | 'popperOptions' | 'theme' | 'title' | 'value'
>;
export function ContextMenuPopper<T>({
menuOptions,
focusedIndex,
isMenuShowing,
popperOptions,
onClose,
referenceElement,
title,
value,
}: ContextMenuPropsType<T>): JSX.Element | null {
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
strategy: 'fixed',
...popperOptions,
});
useEffect(() => {
if (!isMenuShowing) {
return noop;
}
const handleOutsideClick = (event: MouseEvent) => {
if (!referenceElement?.contains(event.target as Node)) {
onClose();
event.stopPropagation();
event.preventDefault();
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.removeEventListener('click', handleOutsideClick);
};
}, [isMenuShowing, onClose, referenceElement]);
if (!isMenuShowing) {
return null;
}
return (
<div
className="ContextMenu__popper"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{title && <div className="ContextMenu__title">{title}</div>}
{menuOptions.map((option, index) => (
<button
aria-label={option.label}
className={classNames({
ContextMenu__option: true,
'ContextMenu__option--focused': focusedIndex === index,
})}
key={option.label}
type="button"
onClick={() => {
option.onClick(option.value);
onClose();
}}
>
<div className="ContextMenu__option--container">
{option.icon && (
<div
className={classNames('ContextMenu__option--icon', option.icon)}
/>
)}
<div>
<div className="ContextMenu__option--title">{option.label}</div>
{option.description && (
<div className="ContextMenu__option--description">
{option.description}
</div>
)}
</div>
</div>
{typeof value !== 'undefined' &&
typeof option.value !== 'undefined' &&
value === option.value ? (
<div className="ContextMenu__option--selected" />
) : null}
</button>
))}
</div>
);
}
export function ContextMenu<T>({
buttonClassName,
i18n,
menuOptions,
onChange,
popperOptions,
theme,
title,
value,
@ -42,13 +145,6 @@ export function ContextMenu<T>({
undefined
);
// We use regular MouseEvent below, and this one uses React.MouseEvent
const handleClick = (ev: KeyboardEvent | React.MouseEvent) => {
setMenuShowing(true);
ev.stopPropagation();
ev.preventDefault();
};
const handleKeyDown = (ev: KeyboardEvent) => {
if (!menuShowing) {
if (ev.key === 'Enter') {
@ -77,7 +173,8 @@ export function ContextMenu<T>({
if (ev.key === 'Enter') {
if (focusedIndex !== undefined) {
onChange(menuOptions[focusedIndex].value);
const focusedOption = menuOptions[focusedIndex];
focusedOption.onClick(focusedOption.value);
}
setMenuShowing(false);
ev.stopPropagation();
@ -85,39 +182,15 @@ export function ContextMenu<T>({
}
};
const handleClose = useCallback(() => {
setMenuShowing(false);
setFocusedIndex(undefined);
}, [setMenuShowing]);
// We use regular MouseEvent below, and this one uses React.MouseEvent
const handleClick = (ev: KeyboardEvent | React.MouseEvent) => {
setMenuShowing(true);
ev.stopPropagation();
ev.preventDefault();
};
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
strategy: 'fixed',
});
useEffect(() => {
if (!menuShowing) {
return noop;
}
const handleOutsideClick = (event: MouseEvent) => {
if (!referenceElement?.contains(event.target as Node)) {
handleClose();
event.stopPropagation();
event.preventDefault();
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.removeEventListener('click', handleOutsideClick);
};
}, [menuShowing, handleClose, referenceElement]);
return (
<div className={theme ? themeClassName(theme) : undefined}>
@ -132,55 +205,22 @@ export function ContextMenu<T>({
ref={setReferenceElement}
type="button"
/>
{menuShowing && (
<div
className="ContextMenu__popper"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{title && <div className="ContextMenu__title">{title}</div>}
{menuOptions.map((option, index) => (
<button
aria-label={option.label}
className={classNames({
ContextMenu__option: true,
'ContextMenu__option--focused': focusedIndex === index,
})}
key={option.label}
type="button"
onClick={() => {
onChange(option.value);
setMenuShowing(false);
}}
>
<div className="ContextMenu__option--container">
{option.icon && (
<div
className={classNames(
'ContextMenu__option--icon',
option.icon
)}
/>
)}
<div>
<div className="ContextMenu__option--title">
{option.label}
</div>
{option.description && (
<div className="ContextMenu__option--description">
{option.description}
</div>
)}
</div>
</div>
{value === option.value ? (
<div className="ContextMenu__option--selected" />
) : null}
</button>
))}
</div>
)}
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true,
}}
>
<ContextMenuPopper
focusedIndex={focusedIndex}
isMenuShowing={menuShowing}
menuOptions={menuOptions}
onClose={() => setMenuShowing(false)}
popperOptions={popperOptions}
referenceElement={referenceElement}
title={title}
value={value}
/>
</FocusTrap>
</div>
);
}

View file

@ -28,10 +28,15 @@ import { ScrollBehavior } from '../types/Util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import { usePrevious } from '../hooks/usePrevious';
import { missingCaseError } from '../util/missingCaseError';
import { strictAssert } from '../util/assert';
import { isSorted } from '../util/isSorted';
import type { WidthBreakpoint } from './_util';
import { getConversationListWidthBreakpoint } from './_util';
import {
MIN_WIDTH,
SNAP_WIDTH,
MIN_FULL_WIDTH,
MAX_WIDTH,
getWidthFromPreferredWidth,
} from '../util/leftPaneWidth';
import { ConversationList } from './ConversationList';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
@ -42,15 +47,6 @@ import type {
SaveAvatarToDiskActionType,
} from '../types/Avatar';
const MIN_WIDTH = 97;
const SNAP_WIDTH = 200;
const MIN_FULL_WIDTH = 280;
const MAX_WIDTH = 380;
strictAssert(
isSorted([MIN_WIDTH, SNAP_WIDTH, MIN_FULL_WIDTH, MAX_WIDTH]),
'Expected widths to be in the right order'
);
export enum LeftPaneMode {
Inbox,
Search,
@ -499,13 +495,6 @@ export const LeftPane: React.FC<PropsType> = ({
selectedConversationId
);
let width: number;
if (requiresFullWidth || preferredWidth >= SNAP_WIDTH) {
width = Math.max(preferredWidth, MIN_FULL_WIDTH);
} else {
width = MIN_WIDTH;
}
const isScrollable = helper.isScrollable();
let rowIndexToScrollTo: undefined | number;
@ -527,6 +516,10 @@ export const LeftPane: React.FC<PropsType> = ({
// It also ensures that we scroll to the top when switching views.
const listKey = preRowsNode ? 1 : 0;
const width = getWidthFromPreferredWidth(preferredWidth, {
requiresFullWidth,
});
const widthBreakpoint = getConversationListWidthBreakpoint(width);
// We disable this lint rule because we're trying to capture bubbled events. See [the

View file

@ -22,6 +22,7 @@ const optionalText = (name: string, value: string | undefined) =>
text(name, value || '') || undefined;
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areStoriesEnabled: false,
theme: ThemeType.light,
phoneNumber: optionalText('phoneNumber', overrideProps.phoneNumber),
@ -37,6 +38,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
showArchivedConversations: action('showArchivedConversations'),
startComposing: action('startComposing'),
toggleProfileEditor: action('toggleProfileEditor'),
toggleStoriesView: action('toggleStoriesView'),
});
story.add('Basic', () => {
@ -68,3 +70,7 @@ story.add('Update Available', () => {
return <MainHeader {...props} />;
});
story.add('Stories', () => (
<MainHeader {...createProps({})} areStoriesEnabled />
));

View file

@ -13,6 +13,7 @@ import type { AvatarColorType } from '../types/Colors';
import type { BadgeType } from '../badges/types';
export type PropsType = {
areStoriesEnabled: boolean;
avatarPath?: string;
badge?: BadgeType;
color?: AvatarColorType;
@ -30,6 +31,7 @@ export type PropsType = {
startComposing: () => void;
startUpdate: () => unknown;
toggleProfileEditor: () => void;
toggleStoriesView: () => unknown;
};
type StateType = {
@ -111,6 +113,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
public override render(): JSX.Element {
const {
areStoriesEnabled,
avatarPath,
badge,
color,
@ -125,6 +128,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
theme,
title,
toggleProfileEditor,
toggleStoriesView,
} = this.props;
const { showingAvatarPopup, popperRoot } = this.state;
@ -204,13 +208,24 @@ export class MainHeader extends React.Component<PropsType, StateType> {
)
: null}
</Manager>
<button
aria-label={i18n('newConversation')}
className="module-main-header__compose-icon"
onClick={startComposing}
title={i18n('newConversation')}
type="button"
/>
<div className="module-main-header__icon-container">
{areStoriesEnabled && (
<button
aria-label={i18n('stories')}
className="module-main-header__stories-icon"
onClick={toggleStoriesView}
title={i18n('stories')}
type="button"
/>
)}
<button
aria-label={i18n('newConversation')}
className="module-main-header__compose-icon"
onClick={startComposing}
title={i18n('newConversation')}
type="button"
/>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Measure from 'react-measure';
@ -582,20 +582,22 @@ export const MediaEditor = ({
{
icon: 'MediaEditor__icon--text-regular',
label: i18n('MediaEditor__text--regular'),
onClick: () => setTextStyle(TextStyle.Regular),
value: TextStyle.Regular,
},
{
icon: 'MediaEditor__icon--text-highlight',
label: i18n('MediaEditor__text--highlight'),
onClick: () => setTextStyle(TextStyle.Highlight),
value: TextStyle.Highlight,
},
{
icon: 'MediaEditor__icon--text-outline',
label: i18n('MediaEditor__text--outline'),
onClick: () => setTextStyle(TextStyle.Outline),
value: TextStyle.Outline,
},
]}
onChange={value => setTextStyle(value)}
theme={Theme.Dark}
value={textStyle}
/>
@ -636,15 +638,16 @@ export const MediaEditor = ({
{
icon: 'MediaEditor__icon--draw-pen',
label: i18n('MediaEditor__draw--pen'),
onClick: () => setDrawTool(DrawTool.Pen),
value: DrawTool.Pen,
},
{
icon: 'MediaEditor__icon--draw-highlighter',
label: i18n('MediaEditor__draw--highlighter'),
onClick: () => setDrawTool(DrawTool.Highlighter),
value: DrawTool.Highlighter,
},
]}
onChange={value => setDrawTool(value)}
theme={Theme.Dark}
value={drawTool}
/>
@ -664,25 +667,28 @@ export const MediaEditor = ({
{
icon: 'MediaEditor__icon--width-thin',
label: i18n('MediaEditor__draw--thin'),
onClick: () => setDrawWidth(DrawWidth.Thin),
value: DrawWidth.Thin,
},
{
icon: 'MediaEditor__icon--width-regular',
label: i18n('MediaEditor__draw--regular'),
onClick: () => setDrawWidth(DrawWidth.Regular),
value: DrawWidth.Regular,
},
{
icon: 'MediaEditor__icon--width-medium',
label: i18n('MediaEditor__draw--medium'),
onClick: () => setDrawWidth(DrawWidth.Medium),
value: DrawWidth.Medium,
},
{
icon: 'MediaEditor__icon--width-heavy',
label: i18n('MediaEditor__draw--heavy'),
onClick: () => setDrawWidth(DrawWidth.Heavy),
value: DrawWidth.Heavy,
},
]}
onChange={value => setDrawWidth(value)}
theme={Theme.Dark}
value={drawWidth}
/>

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement, ReactNode } from 'react';
@ -25,6 +25,7 @@ type PropsType = {
moduleClassName?: string;
onClose?: () => void;
title?: ReactNode;
useFocusTrap?: boolean;
};
type ModalPropsType = PropsType & {
@ -44,6 +45,7 @@ export function Modal({
onClose = noop,
title,
theme,
useFocusTrap,
}: Readonly<ModalPropsType>): ReactElement {
const { close, modalStyles, overlayStyles } = useAnimated(onClose, {
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
@ -55,10 +57,12 @@ export function Modal({
return (
<ModalHost
moduleClassName={moduleClassName}
noMouseClose={noMouseClose}
onClose={close}
overlayStyles={overlayStyles}
theme={theme}
useFocusTrap={useFocusTrap}
>
<animated.div style={modalStyles}>
<ModalWindow

View file

@ -1,4 +1,4 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect } from 'react';
@ -10,28 +10,33 @@ import classNames from 'classnames';
import type { ModalConfigType } from '../hooks/useAnimated';
import type { Theme } from '../util/theme';
import { getClassNamesFor } from '../util/getClassNamesFor';
import { themeClassName } from '../util/theme';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
export type PropsType = Readonly<{
children: React.ReactElement;
moduleClassName?: string;
noMouseClose?: boolean;
onClose: () => unknown;
onEscape?: () => unknown;
onTopOfEverything?: boolean;
overlayStyles?: SpringValues<ModalConfigType>;
theme?: Theme;
onTopOfEverything?: boolean;
useFocusTrap?: boolean;
}>;
export const ModalHost = React.memo(
({
children,
moduleClassName,
noMouseClose,
onClose,
onEscape,
theme,
overlayStyles,
onTopOfEverything,
overlayStyles,
theme,
useFocusTrap = true,
}: PropsType) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
const [isMouseDown, setIsMouseDown] = React.useState(false);
@ -74,26 +79,35 @@ export const ModalHost = React.memo(
theme ? themeClassName(theme) : undefined,
onTopOfEverything ? 'module-modal-host--on-top-of-everything' : undefined,
]);
const getClassName = getClassNamesFor('module-modal-host', moduleClassName);
const modalContent = (
<div className={className}>
<animated.div
role="presentation"
className={getClassName('__overlay')}
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
style={overlayStyles}
/>
<div className={getClassName('__overlay-container')}>{children}</div>
</div>
);
return root
? createPortal(
<FocusTrap
focusTrapOptions={{
// This is alright because the overlay covers the entire screen
allowOutsideClick: false,
}}
>
<div className={className}>
<animated.div
role="presentation"
className="module-modal-host__overlay"
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
style={overlayStyles}
/>
<div className="module-modal-host__container">{children}</div>
</div>
</FocusTrap>,
useFocusTrap ? (
<FocusTrap
focusTrapOptions={{
// This is alright because the overlay covers the entire screen
allowOutsideClick: false,
}}
>
{modalContent}
</FocusTrap>
) : (
modalContent
),
root
)
: null;

View file

@ -0,0 +1,124 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { v4 as uuid } from 'uuid';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { AttachmentType } from '../types/Attachment';
import type { PropsType } from './Stories';
import { Stories } from './Stories';
import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import {
fakeAttachment,
fakeThumbnail,
} from '../test-both/helpers/fakeAttachment';
import * as durations from '../util/durations';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Stories', module);
function createStory({
attachment,
group,
timestamp,
}: {
attachment?: AttachmentType;
group?: { title: string };
timestamp: number;
}) {
const replies = Math.random() > 0.5;
let hasReplies = false;
let hasRepliesFromSelf = false;
if (replies) {
hasReplies = true;
hasRepliesFromSelf = Math.random() > 0.5;
}
const sender = getDefaultConversation();
return {
conversationId: sender.id,
group,
stories: [
{
attachment,
hasReplies,
hasRepliesFromSelf,
isMe: false,
isUnread: Math.random() > 0.5,
messageId: uuid(),
sender,
timestamp,
},
],
};
}
const getDefaultProps = (): PropsType => ({
hiddenStories: [],
i18n,
openConversationInternal: action('openConversationInternal'),
preferredWidthFromStorage: 380,
renderStoryViewer: () => <div />,
stories: [
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
}),
timestamp: Date.now() - 2 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail(
'/fixtures/koushik-chowdavarapu-105425-unsplash.jpg'
),
}),
timestamp: Date.now() - 5 * durations.MINUTE,
}),
createStory({
group: { title: 'BBQ in the park' },
attachment: fakeAttachment({
thumbnail: fakeThumbnail(
'/fixtures/nathan-anderson-316188-unsplash.jpg'
),
}),
timestamp: Date.now() - 65 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/snow.jpg'),
}),
timestamp: Date.now() - 92 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/kitten-1-64-64.jpg'),
}),
timestamp: Date.now() - 164 * durations.MINUTE,
}),
createStory({
group: { title: 'Breaking Signal for Science' },
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/kitten-2-64-64.jpg'),
}),
timestamp: Date.now() - 380 * durations.MINUTE,
}),
createStory({
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/kitten-3-64-64.jpg'),
}),
timestamp: Date.now() - 421 * durations.MINUTE,
}),
],
toggleHideStories: action('toggleHideStories'),
toggleStoriesView: action('toggleStoriesView'),
});
story.add('Blank', () => <Stories {...getDefaultProps()} stories={[]} />);
story.add('Many', () => <Stories {...getDefaultProps()} />);

112
ts/components/Stories.tsx Normal file
View file

@ -0,0 +1,112 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import classNames from 'classnames';
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
import type { LocalizerType } from '../types/Util';
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
import { StoriesPane } from './StoriesPane';
import { Theme, themeClassName } from '../util/theme';
import { getWidthFromPreferredWidth } from '../util/leftPaneWidth';
export type PropsType = {
hiddenStories: Array<ConversationStoryType>;
i18n: LocalizerType;
preferredWidthFromStorage: number;
openConversationInternal: (_: { conversationId: string }) => unknown;
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown;
};
type ViewingStoryType = {
conversationId: string;
stories: Array<StoryViewType>;
};
export const Stories = ({
hiddenStories,
i18n,
openConversationInternal,
preferredWidthFromStorage,
renderStoryViewer,
stories,
toggleHideStories,
toggleStoriesView,
}: PropsType): JSX.Element => {
const [storiesToView, setStoriesToView] = useState<
undefined | ViewingStoryType
>();
const width = getWidthFromPreferredWidth(preferredWidthFromStorage, {
requiresFullWidth: true,
});
return (
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
{storiesToView &&
renderStoryViewer({
conversationId: storiesToView.conversationId,
onClose: () => setStoriesToView(undefined),
onNextUserStories: () => {
const storyIndex = stories.findIndex(
x => x.conversationId === storiesToView.conversationId
);
if (storyIndex >= stories.length - 1) {
setStoriesToView(undefined);
return;
}
const nextStory = stories[storyIndex + 1];
setStoriesToView({
conversationId: nextStory.conversationId,
stories: nextStory.stories,
});
},
onPrevUserStories: () => {
const storyIndex = stories.findIndex(
x => x.conversationId === storiesToView.conversationId
);
if (storyIndex === 0) {
setStoriesToView(undefined);
return;
}
const prevStory = stories[storyIndex - 1];
setStoriesToView({
conversationId: prevStory.conversationId,
stories: prevStory.stories,
});
},
stories: storiesToView.stories,
})}
<div className="Stories__pane" style={{ width }}>
<StoriesPane
hiddenStories={hiddenStories}
i18n={i18n}
onBack={toggleStoriesView}
onStoryClicked={conversationId => {
const storyIndex = stories.findIndex(
x => x.conversationId === conversationId
);
const foundStory = stories[storyIndex];
if (foundStory) {
setStoriesToView({
conversationId,
stories: foundStory.stories,
});
}
}}
openConversationInternal={openConversationInternal}
stories={stories}
toggleHideStories={toggleHideStories}
/>
</div>
<div className="Stories__placeholder">
<div className="Stories__placeholder__stories" />
{i18n('Stories__placeholder--text')}
</div>
</div>
);
};

View file

@ -0,0 +1,124 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FuseOptions } from 'fuse.js';
import Fuse from 'fuse.js';
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
import type { LocalizerType } from '../types/Util';
import { SearchInput } from './SearchInput';
import { StoryListItem } from './StoryListItem';
const FUSE_OPTIONS: FuseOptions<ConversationStoryType> = {
getFn: (obj, path) => {
if (path === 'searchNames') {
return obj.stories.flatMap((story: StoryViewType) => [
story.sender.title,
story.sender.name,
]);
}
return obj.group?.title;
},
keys: [
{
name: 'searchNames',
weight: 1,
},
{
name: 'group',
weight: 1,
},
],
threshold: 0.1,
tokenize: true,
};
function search(
stories: ReadonlyArray<ConversationStoryType>,
searchTerm: string
): Array<ConversationStoryType> {
return new Fuse<ConversationStoryType>(stories, FUSE_OPTIONS).search(
searchTerm
);
}
export type PropsType = {
hiddenStories: Array<ConversationStoryType>;
i18n: LocalizerType;
onBack: () => unknown;
onStoryClicked: (conversationId: string) => unknown;
openConversationInternal: (_: { conversationId: string }) => unknown;
stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown;
};
export const StoriesPane = ({
i18n,
onBack,
onStoryClicked,
openConversationInternal,
stories,
toggleHideStories,
}: PropsType): JSX.Element => {
const [searchTerm, setSearchTerm] = useState('');
const [renderedStories, setRenderedStories] =
useState<Array<ConversationStoryType>>(stories);
useEffect(() => {
if (searchTerm) {
setRenderedStories(search(stories, searchTerm));
} else {
setRenderedStories(stories);
}
}, [searchTerm, stories]);
return (
<>
<div className="Stories__pane__header">
<button
aria-label={i18n('back')}
className="Stories__pane__header--back"
onClick={onBack}
type="button"
/>
<div className="Stories__pane__header--title">
{i18n('Stories__title')}
</div>
</div>
<SearchInput
i18n={i18n}
moduleClassName="Stories__search"
onChange={event => {
setSearchTerm(event.target.value);
}}
placeholder={i18n('search')}
value={searchTerm}
/>
<div
className={classNames('Stories__pane__list', {
'Stories__pane__list--empty': !stories.length,
})}
>
{renderedStories.map(story => (
<StoryListItem
key={story.stories[0].timestamp}
i18n={i18n}
onClick={() => {
onStoryClicked(story.conversationId);
}}
onHideStory={() => {
toggleHideStories(story.stories[0].sender.id);
}}
onGoToConversation={conversationId => {
openConversationInternal({ conversationId });
}}
story={story.stories[0]}
/>
))}
{!stories.length && i18n('Stories__list-empty')}
</div>
</>
);
};

View file

@ -0,0 +1,77 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryListItem';
import { StoryListItem } from './StoryListItem';
import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import {
fakeAttachment,
fakeThumbnail,
} from '../test-both/helpers/fakeAttachment';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/StoryListItem', module);
function getDefaultProps(): PropsType {
return {
i18n,
onClick: action('onClick'),
story: {
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
};
}
story.add('My Story', () => (
<StoryListItem
{...getDefaultProps()}
story={{
messageId: '123',
sender: getDefaultConversation({ isMe: true }),
timestamp: Date.now(),
}}
/>
));
story.add('My Story (many)', () => (
<StoryListItem
{...getDefaultProps()}
story={{
attachment: fakeAttachment({
thumbnail: fakeThumbnail(
'/fixtures/nathan-anderson-316188-unsplash.jpg'
),
}),
messageId: '123',
sender: getDefaultConversation({ isMe: true }),
timestamp: Date.now(),
}}
hasMultiple
/>
));
story.add("Someone's story", () => (
<StoryListItem
{...getDefaultProps()}
group={{ title: 'Sports Group' }}
story={{
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
}),
hasReplies: true,
isUnread: true,
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
}}
/>
));

View file

@ -0,0 +1,240 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import classNames from 'classnames';
import type { AttachmentType } from '../types/Attachment';
import type { LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import { Avatar, AvatarSize, AvatarStoryRing } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenuPopper } from './ContextMenu';
import { getAvatarColor } from '../types/Colors';
import { MessageTimestamp } from './conversation/MessageTimestamp';
export type ConversationStoryType = {
conversationId: string;
group?: Pick<ConversationType, 'title'>;
hasMultiple?: boolean;
isHidden?: boolean;
searchNames?: string; // This is just here to satisfy Fuse's types
stories: Array<StoryViewType>;
};
export type StoryViewType = {
attachment?: AttachmentType;
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
isHidden?: boolean;
isUnread?: boolean;
messageId: string;
selectedReaction?: string;
sender: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'firstName'
| 'id'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
timestamp: number;
};
export type PropsType = Pick<
ConversationStoryType,
'group' | 'hasMultiple' | 'isHidden'
> & {
i18n: LocalizerType;
onClick: () => unknown;
onGoToConversation?: (conversationId: string) => unknown;
onHideStory?: (conversationId: string) => unknown;
story: StoryViewType;
};
export const StoryListItem = ({
group,
hasMultiple,
i18n,
isHidden,
onClick,
onGoToConversation,
onHideStory,
story,
}: PropsType): JSX.Element => {
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
const [isShowingContextMenu, setIsShowingContextMenu] = useState(false);
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const {
attachment,
hasReplies,
hasRepliesFromSelf,
isUnread,
sender,
timestamp,
} = story;
const {
acceptedMessageRequest,
avatarPath,
color,
firstName,
isMe,
name,
profileName,
sharedGroupNames,
title,
} = sender;
let avatarStoryRing: AvatarStoryRing | undefined;
if (attachment) {
avatarStoryRing = isUnread ? AvatarStoryRing.Unread : AvatarStoryRing.Read;
}
let repliesElement: JSX.Element | undefined;
if (hasRepliesFromSelf) {
repliesElement = <div className="StoryListItem__info--replies--self" />;
} else if (hasReplies) {
repliesElement = <div className="StoryListItem__info--replies--others" />;
}
return (
<>
<button
aria-label={i18n('StoryListItem__label')}
className={classNames('StoryListItem', {
'StoryListItem--hidden': isHidden,
})}
onClick={onClick}
onContextMenu={ev => {
ev.preventDefault();
ev.stopPropagation();
if (!isMe) {
setIsShowingContextMenu(true);
}
}}
ref={setReferenceElement}
type="button"
>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
sharedGroupNames={sharedGroupNames}
avatarPath={avatarPath}
badge={undefined}
color={getAvatarColor(color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(isMe)}
name={name}
profileName={profileName}
size={AvatarSize.FORTY_EIGHT}
storyRing={avatarStoryRing}
title={title}
/>
<div className="StoryListItem__info">
{isMe ? (
<>
<div className="StoryListItem__info--title">
{i18n('Stories__mine')}
</div>
{!attachment && (
<div className="StoryListItem__info--timestamp">
{i18n('Stories__add')}
</div>
)}
</>
) : (
<>
<div className="StoryListItem__info--title">
{group
? i18n('Stories__from-to-group', {
name: title,
group: group.title,
})
: title}
</div>
<MessageTimestamp
i18n={i18n}
module="StoryListItem__info--timestamp"
now={Date.now()}
timestamp={timestamp}
/>
</>
)}
{repliesElement}
</div>
<div
className={classNames('StoryListItem__previews', {
'StoryListItem__previews--multiple': hasMultiple,
})}
>
{!attachment && isMe && (
<div
aria-label={i18n('Stories__add')}
className="StoryListItem__previews--add StoryListItem__previews--image"
/>
)}
{hasMultiple && <div className="StoryListItem__previews--more" />}
{attachment && (
<div
className="StoryListItem__previews--image"
style={{
backgroundImage: `url("${attachment.thumbnail?.url}")`,
}}
/>
)}
</div>
</button>
<ContextMenuPopper
isMenuShowing={isShowingContextMenu}
menuOptions={[
{
icon: 'StoryListItem__icon--hide',
label: i18n('StoryListItem__hide'),
onClick: () => {
setHasConfirmHideStory(true);
},
},
{
icon: 'StoryListItem__icon--chat',
label: i18n('StoryListItem__go-to-chat'),
onClick: () => {
onGoToConversation?.(sender.id);
},
},
]}
onClose={() => setIsShowingContextMenu(false)}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
referenceElement={referenceElement}
/>
{hasConfirmHideStory && (
<ConfirmationDialog
actions={[
{
action: () => onHideStory?.(sender.id),
style: 'affirmative',
text: i18n('StoryListItem__hide-modal--confirm'),
},
]}
i18n={i18n}
onClose={() => {
setHasConfirmHideStory(false);
}}
>
{i18n('StoryListItem__hide-modal--body', [String(firstName)])}
</ConfirmationDialog>
)}
</>
);
};

View file

@ -0,0 +1,121 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryViewer';
import { StoryViewer } from './StoryViewer';
import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/StoryViewer', module);
function getDefaultProps(): PropsType {
return {
getPreferredBadge: () => undefined,
group: undefined,
i18n,
markStoryRead: action('markStoryRead'),
onClose: action('onClose'),
onNextUserStories: action('onNextUserStories'),
onPrevUserStories: action('onPrevUserStories'),
onReactToStory: action('onReactToStory'),
onReplyToStory: action('onReplyToStory'),
onSetSkinTone: action('onSetSkinTone'),
onTextTooLong: action('onTextTooLong'),
onUseEmoji: action('onUseEmoji'),
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'],
renderEmojiPicker: () => <div />,
replies: Math.floor(Math.random() * 20),
stories: [
{
attachment: fakeAttachment({
url: '/fixtures/snow.jpg',
}),
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
],
views: Math.floor(Math.random() * 20),
};
}
story.add("Someone's story", () => <StoryViewer {...getDefaultProps()} />);
story.add('Wide story', () => (
<StoryViewer
{...getDefaultProps()}
stories={[
{
attachment: fakeAttachment({
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
}),
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
]}
/>
));
story.add('In a group', () => (
<StoryViewer
{...getDefaultProps()}
group={getDefaultConversation({
avatarPath: '/fixtures/kitten-4-112-112.jpg',
title: 'Family Group',
type: 'group',
})}
/>
));
story.add('Multi story', () => {
const sender = getDefaultConversation();
return (
<StoryViewer
{...getDefaultProps()}
stories={[
{
attachment: fakeAttachment({
url: '/fixtures/snow.jpg',
}),
messageId: '123',
sender,
timestamp: Date.now(),
},
{
attachment: fakeAttachment({
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
}),
messageId: '456',
sender,
timestamp: Date.now() - 3600,
},
]}
/>
);
});
story.add('So many stories', () => {
const sender = getDefaultConversation();
return (
<StoryViewer
{...getDefaultProps()}
stories={Array(20).fill({
attachment: fakeAttachment({
url: '/fixtures/snow.jpg',
}),
messageId: '123',
sender,
timestamp: Date.now(),
})}
/>
);
});

View file

@ -0,0 +1,337 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useState } from 'react';
import { useSpring, animated, to } from '@react-spring/web';
import type { BodyRangeType, LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
import type { StoryViewType } from './StoryListItem';
import { Avatar, AvatarSize } from './Avatar';
import { Intl } from './Intl';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
import { getAvatarColor } from '../types/Colors';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
const STORY_DURATION = 5000;
export type PropsType = {
getPreferredBadge: PreferredBadgeSelectorType;
group?: ConversationType;
i18n: LocalizerType;
markStoryRead: (mId: string) => unknown;
onClose: () => unknown;
onNextUserStories: () => unknown;
onPrevUserStories: () => unknown;
onSetSkinTone: (tone: number) => unknown;
onTextTooLong: () => unknown;
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
onReplyToStory: (
message: string,
mentions: Array<BodyRangeType>,
timestamp: number,
story: StoryViewType
) => unknown;
onUseEmoji: (_: EmojiPickDataType) => unknown;
preferredReactionEmoji: Array<string>;
recentEmojis?: Array<string>;
replies?: number;
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
skinTone?: number;
stories: Array<StoryViewType>;
views?: number;
};
export const StoryViewer = ({
getPreferredBadge,
group,
i18n,
markStoryRead,
onClose,
onNextUserStories,
onPrevUserStories,
onReactToStory,
onReplyToStory,
onSetSkinTone,
onTextTooLong,
onUseEmoji,
preferredReactionEmoji,
recentEmojis,
renderEmojiPicker,
replies,
skinTone,
stories,
views,
}: PropsType): JSX.Element => {
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
const visibleStory = stories[currentStoryIndex];
const {
acceptedMessageRequest,
avatarPath,
color,
isMe,
name,
profileName,
sharedGroupNames,
title,
} = visibleStory.sender;
const [hasReplyModal, setHasReplyModal] = useState(false);
const onEscape = useCallback(() => {
if (hasReplyModal) {
setHasReplyModal(false);
} else {
onClose();
}
}, [hasReplyModal, onClose]);
useEscapeHandling(onEscape);
const showNextStory = useCallback(() => {
// Either we show the next story in the current user's stories or we ask
// for the next user's stories.
if (currentStoryIndex < stories.length - 1) {
setCurrentStoryIndex(currentStoryIndex + 1);
} else {
onNextUserStories();
}
}, [currentStoryIndex, onNextUserStories, stories.length]);
const showPrevStory = useCallback(() => {
// Either we show the previous story in the current user's stories or we ask
// for the prior user's stories.
if (currentStoryIndex === 0) {
onPrevUserStories();
} else {
setCurrentStoryIndex(currentStoryIndex - 1);
}
}, [currentStoryIndex, onPrevUserStories]);
const [styles, spring] = useSpring(() => ({
config: {
duration: STORY_DURATION,
},
from: { width: 0 },
to: { width: 100 },
loop: true,
}));
// Adding "currentStoryIndex" to the dependency list here to explcitly signal
// that this useEffect should run whenever the story changes.
useEffect(() => {
spring.start({
from: { width: 0 },
to: { width: 100 },
onRest: showNextStory,
});
}, [currentStoryIndex, showNextStory, spring]);
useEffect(() => {
if (hasReplyModal) {
spring.pause();
} else {
spring.resume();
}
}, [hasReplyModal, spring]);
useEffect(() => {
markStoryRead(visibleStory.messageId);
}, [markStoryRead, visibleStory.messageId]);
const navigateStories = useCallback(
(ev: KeyboardEvent) => {
if (ev.key === 'ArrowRight') {
showNextStory();
ev.preventDefault();
ev.stopPropagation();
} else if (ev.key === 'ArrowLeft') {
showPrevStory();
ev.preventDefault();
ev.stopPropagation();
}
},
[showPrevStory, showNextStory]
);
useEffect(() => {
document.addEventListener('keydown', navigateStories);
return () => {
document.removeEventListener('keydown', navigateStories);
};
}, [navigateStories]);
return (
<div className="StoryViewer">
<div className="StoryViewer__overlay" />
<div className="StoryViewer__content">
<button
aria-label={i18n('MyStories__more')}
className="StoryViewer__more"
type="button"
/>
<button
aria-label={i18n('close')}
className="StoryViewer__close-button"
onClick={onClose}
type="button"
/>
<div className="StoryViewer__container">
{visibleStory.attachment && (
<img
alt={i18n('lightboxImageAlt')}
className="StoryViewer__story"
src={visibleStory.attachment.url}
/>
)}
<div className="StoryViewer__meta">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
badge={undefined}
color={getAvatarColor(color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(isMe)}
name={name}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
title={title}
/>
{group && (
<Avatar
acceptedMessageRequest={group.acceptedMessageRequest}
avatarPath={group.avatarPath}
badge={undefined}
className="StoryViewer__meta--group-avatar"
color={getAvatarColor(group.color)}
conversationType="group"
i18n={i18n}
isMe={false}
name={group.name}
profileName={group.profileName}
sharedGroupNames={group.sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
title={group.title}
/>
)}
<div className="StoryViewer__meta--title">
{group
? i18n('Stories__from-to-group', {
name: title,
group: group.title,
})
: title}
</div>
<MessageTimestamp
i18n={i18n}
module="StoryViewer__meta--timestamp"
now={Date.now()}
timestamp={visibleStory.timestamp}
/>
<div className="StoryViewer__progress">
{stories.map((story, index) => (
<div
className="StoryViewer__progress--container"
key={story.timestamp}
>
{currentStoryIndex === index ? (
<animated.div
className="StoryViewer__progress--bar"
style={{
width: to([styles.width], width => `${width}%`),
}}
/>
) : (
<div
className="StoryViewer__progress--bar"
style={{
width: currentStoryIndex < index ? '0%' : '100%',
}}
/>
)}
</div>
))}
</div>
</div>
</div>
<div className="StoryViewer__actions">
{isMe ? (
<>
{views &&
(views === 1 ? (
<Intl
i18n={i18n}
id="MyStories__views--singular"
components={[<strong>{views}</strong>]}
/>
) : (
<Intl
i18n={i18n}
id="MyStories__views--plural"
components={[<strong>{views}</strong>]}
/>
))}
{views && replies && ' '}
{replies &&
(replies === 1 ? (
<Intl
i18n={i18n}
id="MyStories__replies--singular"
components={[<strong>{replies}</strong>]}
/>
) : (
<Intl
i18n={i18n}
id="MyStories__replies--plural"
components={[<strong>{replies}</strong>]}
/>
))}
</>
) : (
<button
className="StoryViewer__reply"
onClick={() => setHasReplyModal(true)}
type="button"
>
{i18n('StoryViewer__reply')}
</button>
)}
</div>
</div>
{hasReplyModal && (
<StoryViewsNRepliesModal
authorTitle={title}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
isMyStory={isMe}
onClose={() => setHasReplyModal(false)}
onReact={emoji => {
onReactToStory(emoji, visibleStory);
}}
onReply={(message, mentions, timestamp) => {
setHasReplyModal(false);
onReplyToStory(message, mentions, timestamp, visibleStory);
}}
onSetSkinTone={onSetSkinTone}
onTextTooLong={onTextTooLong}
onUseEmoji={onUseEmoji}
preferredReactionEmoji={preferredReactionEmoji}
recentEmojis={recentEmojis}
renderEmojiPicker={renderEmojiPicker}
replies={[]}
skinTone={skinTone}
storyPreviewAttachment={visibleStory.attachment}
views={[]}
/>
)}
</div>
);
};

View file

@ -0,0 +1,125 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryViewsNRepliesModal';
import * as durations from '../util/durations';
import enMessages from '../../_locales/en/messages.json';
import { IMAGE_JPEG } from '../types/MIME';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/StoryViewsNRepliesModal', module);
function getDefaultProps(): PropsType {
return {
authorTitle: getDefaultConversation().title,
getPreferredBadge: () => undefined,
i18n,
isMyStory: false,
onClose: action('onClose'),
onSetSkinTone: action('onSetSkinTone'),
onReact: action('onReact'),
onReply: action('onReply'),
onTextTooLong: action('onTextTooLong'),
onUseEmoji: action('onUseEmoji'),
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'],
renderEmojiPicker: () => <div />,
replies: [],
storyPreviewAttachment: fakeAttachment({
thumbnail: {
contentType: IMAGE_JPEG,
height: 64,
objectUrl: '/fixtures/nathan-anderson-316188-unsplash.jpg',
path: '',
width: 40,
},
}),
views: [],
};
}
function getViewsAndReplies() {
const p1 = getDefaultConversation();
const p2 = getDefaultConversation();
const p3 = getDefaultConversation();
const p4 = getDefaultConversation();
const p5 = getDefaultConversation();
const views = [
{
...p1,
timestamp: Date.now() - 20 * durations.MINUTE,
},
{
...p2,
timestamp: Date.now() - 25 * durations.MINUTE,
},
{
...p3,
timestamp: Date.now() - 15 * durations.MINUTE,
},
{
...p4,
timestamp: Date.now() - 5 * durations.MINUTE,
},
{
...p5,
timestamp: Date.now() - 30 * durations.MINUTE,
},
];
const replies = [
{
...p2,
body: 'So cute ❤️',
timestamp: Date.now() - 24 * durations.MINUTE,
},
{
...p3,
body: "That's awesome",
timestamp: Date.now() - 13 * durations.MINUTE,
},
{
...p4,
reactionEmoji: '❤️',
timestamp: Date.now() - 5 * durations.MINUTE,
},
];
return {
views,
replies,
};
}
story.add('Can reply', () => (
<StoryViewsNRepliesModal {...getDefaultProps()} />
));
story.add('Views only', () => (
<StoryViewsNRepliesModal
{...getDefaultProps()}
isMyStory
views={getViewsAndReplies().views}
/>
));
story.add('In a group', () => {
const { views, replies } = getViewsAndReplies();
return (
<StoryViewsNRepliesModal
{...getDefaultProps()}
replies={replies}
views={views}
/>
);
});

View file

@ -0,0 +1,388 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import { usePopper } from 'react-popper';
import type { AttachmentType } from '../types/Attachment';
import type { BodyRangeType, LocalizerType } from '../types/Util';
import type { ContactNameColorType } from '../types/Colors';
import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { InputApi } from './CompositionInput';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
import { Avatar, AvatarSize } from './Avatar';
import { CompositionInput } from './CompositionInput';
import { ContactName } from './conversation/ContactName';
import { EmojiButton } from './emoji/EmojiButton';
import { Emojify } from './conversation/Emojify';
import { MessageBody } from './conversation/MessageBody';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { Modal } from './Modal';
import { Quote } from './conversation/Quote';
import { ReactionPicker } from './conversation/ReactionPicker';
import { Tabs } from './Tabs';
import { ThemeType } from '../types/Util';
import { getAvatarColor } from '../types/Colors';
type ReplyType = Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
> & {
body?: string;
contactNameColor?: ContactNameColorType;
reactionEmoji?: string;
timestamp: number;
};
type ViewType = Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
> & {
contactNameColor?: ContactNameColorType;
timestamp: number;
};
enum Tab {
Replies = 'Replies',
Views = 'Views',
}
export type PropsType = {
authorTitle: string;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
isMyStory?: boolean;
onClose: () => unknown;
onReact: (emoji: string) => unknown;
onReply: (
message: string,
mentions: Array<BodyRangeType>,
timestamp: number
) => unknown;
onSetSkinTone: (tone: number) => unknown;
onTextTooLong: () => unknown;
onUseEmoji: (_: EmojiPickDataType) => unknown;
preferredReactionEmoji: Array<string>;
recentEmojis?: Array<string>;
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
replies: Array<ReplyType>;
skinTone?: number;
storyPreviewAttachment?: AttachmentType;
views: Array<ViewType>;
};
export const StoryViewsNRepliesModal = ({
authorTitle,
getPreferredBadge,
i18n,
isMyStory,
onClose,
onReact,
onReply,
onSetSkinTone,
onTextTooLong,
onUseEmoji,
preferredReactionEmoji,
recentEmojis,
renderEmojiPicker,
replies,
skinTone,
storyPreviewAttachment,
views,
}: PropsType): JSX.Element => {
const inputApiRef = React.useRef<InputApi | undefined>();
const [messageBodyText, setMessageBodyText] = useState('');
const [showReactionPicker, setShowReactionPicker] = useState(false);
const focusComposer = useCallback(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
}
}, [inputApiRef]);
const insertEmoji = useCallback(
(e: EmojiPickDataType) => {
if (inputApiRef.current) {
inputApiRef.current.insertEmoji(e);
onUseEmoji(e);
}
},
[inputApiRef, onUseEmoji]
);
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'top-start',
strategy: 'fixed',
});
let composerElement: JSX.Element | undefined;
if (!isMyStory) {
composerElement = (
<div className="StoryViewsNRepliesModal__compose-container">
<div className="StoryViewsNRepliesModal__composer">
{!replies.length && (
<Quote
authorTitle={authorTitle}
conversationColor="steel"
i18n={i18n}
isFromMe={false}
isViewOnce={false}
rawAttachment={storyPreviewAttachment}
referencedMessageNotFound={false}
text={i18n('message--getNotificationText--text-with-emoji', {
text: i18n('message--getNotificationText--photo'),
emoji: '📷',
})}
/>
)}
<CompositionInput
draftText={messageBodyText}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
inputApi={inputApiRef}
moduleClassName="StoryViewsNRepliesModal__input"
onEditorStateChange={messageText => {
setMessageBodyText(messageText);
}}
onPickEmoji={insertEmoji}
onSubmit={onReply}
onTextTooLong={onTextTooLong}
placeholder={i18n('StoryViewsNRepliesModal__placeholder')}
theme={ThemeType.dark}
>
<EmojiButton
className="StoryViewsNRepliesModal__emoji-button"
i18n={i18n}
onPickEmoji={insertEmoji}
onClose={focusComposer}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</CompositionInput>
</div>
<button
aria-label={i18n('StoryViewsNRepliesModal__react')}
className="StoryViewsNRepliesModal__react"
onClick={() => {
setShowReactionPicker(!showReactionPicker);
}}
ref={setReferenceElement}
type="button"
/>
{showReactionPicker && (
<div
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<ReactionPicker
i18n={i18n}
onClose={() => {
setShowReactionPicker(false);
}}
onPick={emoji => {
setShowReactionPicker(false);
onReact(emoji);
}}
onSetSkinTone={onSetSkinTone}
preferredReactionEmoji={preferredReactionEmoji}
renderEmojiPicker={renderEmojiPicker}
/>
</div>
)}
</div>
);
}
const repliesElement = replies.length ? (
<div className="StoryViewsNRepliesModal__replies">
{replies.map(reply =>
reply.reactionEmoji ? (
<div className="StoryViewsNRepliesModal__reaction">
<div className="StoryViewsNRepliesModal__reaction--container">
<Avatar
acceptedMessageRequest={reply.acceptedMessageRequest}
avatarPath={reply.avatarPath}
badge={undefined}
color={getAvatarColor(reply.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(reply.isMe)}
name={reply.name}
profileName={reply.profileName}
sharedGroupNames={reply.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
title={reply.title}
/>
<div className="StoryViewsNRepliesModal__reaction--body">
<div className="StoryViewsNRepliesModal__reply--title">
<ContactName
contactNameColor={reply.contactNameColor}
title={reply.title}
/>
</div>
{i18n('StoryViewsNRepliesModal__reacted')}
<MessageTimestamp
i18n={i18n}
module="StoryViewsNRepliesModal__reply--timestamp"
now={Date.now()}
timestamp={reply.timestamp}
/>
</div>
</div>
<Emojify text={reply.reactionEmoji} />
</div>
) : (
<div className="StoryViewsNRepliesModal__reply">
<Avatar
acceptedMessageRequest={reply.acceptedMessageRequest}
avatarPath={reply.avatarPath}
badge={undefined}
color={getAvatarColor(reply.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(reply.isMe)}
name={reply.name}
profileName={reply.profileName}
sharedGroupNames={reply.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
title={reply.title}
/>
<div className="StoryViewsNRepliesModal__message-bubble">
<div className="StoryViewsNRepliesModal__reply--title">
<ContactName
contactNameColor={reply.contactNameColor}
title={reply.title}
/>
</div>
<MessageBody i18n={i18n} text={String(reply.body)} />
<MessageTimestamp
i18n={i18n}
module="StoryViewsNRepliesModal__reply--timestamp"
now={Date.now()}
timestamp={reply.timestamp}
/>
</div>
</div>
)
)}
</div>
) : undefined;
const viewsElement = views.length ? (
<div className="StoryViewsNRepliesModal__views">
{views.map(view => (
<div className="StoryViewsNRepliesModal__view" key={view.timestamp}>
<div>
<Avatar
acceptedMessageRequest={view.acceptedMessageRequest}
avatarPath={view.avatarPath}
badge={undefined}
color={getAvatarColor(view.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(view.isMe)}
name={view.name}
profileName={view.profileName}
sharedGroupNames={view.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
title={view.title}
/>
<span className="StoryViewsNRepliesModal__view--name">
<ContactName
contactNameColor={view.contactNameColor}
title={view.title}
/>
</span>
</div>
<MessageTimestamp
i18n={i18n}
module="StoryViewsNRepliesModal__view--timestamp"
now={Date.now()}
timestamp={view.timestamp}
/>
</div>
))}
</div>
) : undefined;
const tabsElement =
views.length && replies.length ? (
<Tabs
initialSelectedTab={Tab.Views}
moduleClassName="StoryViewsNRepliesModal__tabs"
tabs={[
{
id: Tab.Views,
label: i18n('StoryViewsNRepliesModal__tab--views'),
},
{
id: Tab.Replies,
label: i18n('StoryViewsNRepliesModal__tab--replies'),
},
]}
>
{({ selectedTab }) => (
<>
{selectedTab === Tab.Views && viewsElement}
{selectedTab === Tab.Replies && (
<>
{repliesElement}
{composerElement}
</>
)}
</>
)}
</Tabs>
) : undefined;
const hasOnlyViewsElement =
viewsElement && !repliesElement && !composerElement;
return (
<Modal
i18n={i18n}
moduleClassName={classNames('StoryViewsNRepliesModal', {
'StoryViewsNRepliesModal--group': Boolean(
views.length && replies.length
),
})}
onClose={onClose}
useFocusTrap={!hasOnlyViewsElement}
>
{tabsElement || (
<>
{viewsElement}
{repliesElement}
{composerElement}
</>
)}
</Modal>
);
};

View file

@ -1,24 +1,15 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEvent, ReactNode } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import { assert } from '../util/assert';
import { getClassNamesFor } from '../util/getClassNamesFor';
import type { ReactNode } from 'react';
import React from 'react';
type Tab = {
id: string;
label: string;
};
import type { TabsOptionsType } from '../hooks/useTabs';
import { useTabs } from '../hooks/useTabs';
type PropsType = {
children: (renderProps: { selectedTab: string }) => ReactNode;
initialSelectedTab?: string;
moduleClassName?: string;
onTabChange?: (selectedTab: string) => unknown;
tabs: Array<Tab>;
};
} & TabsOptionsType;
export const Tabs = ({
children,
@ -27,42 +18,16 @@ export const Tabs = ({
onTabChange,
tabs,
}: PropsType): JSX.Element => {
assert(tabs.length, 'Tabs needs more than 1 tab present');
const [selectedTab, setSelectedTab] = useState<string>(
initialSelectedTab || tabs[0].id
);
const getClassName = getClassNamesFor('Tabs', moduleClassName);
const { selectedTab, tabsHeaderElement } = useTabs({
initialSelectedTab,
moduleClassName,
onTabChange,
tabs,
});
return (
<>
<div className={getClassName('')}>
{tabs.map(({ id, label }) => (
<div
className={classNames(
getClassName('__tab'),
selectedTab === id && getClassName('__tab--selected')
)}
key={id}
onClick={() => {
setSelectedTab(id);
onTabChange?.(id);
}}
onKeyUp={(e: KeyboardEvent) => {
if (e.target === e.currentTarget && e.keyCode === 13) {
setSelectedTab(id);
e.preventDefault();
e.stopPropagation();
}
}}
role="tab"
tabIndex={0}
>
{label}
</div>
))}
</div>
{tabsHeaderElement}
{children({ selectedTab })}
</>
);

View file

@ -213,6 +213,9 @@ story.add('Image Only', () => {
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
},
@ -228,6 +231,9 @@ story.add('Image Attachment', () => {
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
},
@ -270,6 +276,9 @@ story.add('Video Only', () => {
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
},
@ -288,6 +297,9 @@ story.add('Video Attachment', () => {
isVoiceMessage: false,
thumbnail: {
contentType: IMAGE_PNG,
height: 100,
width: 100,
path: pngUrl,
objectUrl: pngUrl,
},
},

View file

@ -1,4 +1,4 @@
// Copyright 2018-2021 Signal Messenger, LLC
// Copyright 2018-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
@ -10,6 +10,7 @@ import * as MIME from '../../types/MIME';
import * as GoogleChrome from '../../util/GoogleChrome';
import { MessageBody } from './MessageBody';
import type { AttachmentType, ThumbnailType } from '../../types/Attachment';
import type { BodyRangesType, LocalizerType } from '../../types/Util';
import type {
ConversationColorType,
@ -40,19 +41,10 @@ type State = {
imageBroken: boolean;
};
export type QuotedAttachmentType = {
contentType: MIME.MIMEType;
fileName?: string;
/** Not included in protobuf */
isVoiceMessage: boolean;
thumbnail?: Attachment;
};
type Attachment = {
contentType: MIME.MIMEType;
/** Not included in protobuf, and is loaded asynchronously */
objectUrl?: string;
};
export type QuotedAttachmentType = Pick<
AttachmentType,
'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail'
>;
function validateQuote(quote: Props): boolean {
if (quote.text) {
@ -75,12 +67,12 @@ function getAttachment(
: undefined;
}
function getObjectUrl(thumbnail: Attachment | undefined): string | undefined {
if (thumbnail && thumbnail.objectUrl) {
return thumbnail.objectUrl;
function getUrl(thumbnail?: ThumbnailType): string | undefined {
if (!thumbnail) {
return;
}
return undefined;
return thumbnail.objectUrl || thumbnail.url;
}
function getTypeLabel({
@ -92,7 +84,7 @@ function getTypeLabel({
i18n: LocalizerType;
isViewOnce?: boolean;
contentType: MIME.MIMEType;
isVoiceMessage: boolean;
isVoiceMessage?: boolean;
}): string | undefined {
if (GoogleChrome.isVideoTypeSupported(contentType)) {
if (isViewOnce) {
@ -249,20 +241,20 @@ export class Quote extends React.Component<Props, State> {
}
const { contentType, thumbnail } = attachment;
const objectUrl = getObjectUrl(thumbnail);
const url = getUrl(thumbnail);
if (isViewOnce) {
return this.renderIcon('view-once');
}
if (GoogleChrome.isVideoTypeSupported(contentType)) {
return objectUrl && !imageBroken
? this.renderImage(objectUrl, 'play')
return url && !imageBroken
? this.renderImage(url, 'play')
: this.renderIcon('movie');
}
if (GoogleChrome.isImageTypeSupported(contentType)) {
return objectUrl && !imageBroken
? this.renderImage(objectUrl)
return url && !imageBroken
? this.renderImage(url)
: this.renderIcon('image');
}
if (MIME.isAudio(contentType)) {

View file

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -12,6 +12,7 @@ import { EmojiPicker } from './EmojiPicker';
import type { LocalizerType } from '../../types/Util';
export type OwnProps = {
readonly className?: string;
readonly closeOnPick?: boolean;
readonly emoji?: string;
readonly i18n: LocalizerType;
@ -26,6 +27,7 @@ export type Props = OwnProps &
export const EmojiButton = React.memo(
({
className,
closeOnPick,
emoji,
i18n,
@ -117,7 +119,7 @@ export const EmojiButton = React.memo(
type="button"
ref={ref}
onClick={handleClickButton}
className={classNames({
className={classNames(className, {
'module-emoji-button__button': true,
'module-emoji-button__button--active': open,
'module-emoji-button__button--has-emoji': Boolean(emoji),

72
ts/hooks/useTabs.tsx Normal file
View file

@ -0,0 +1,72 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEvent } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import { assert } from '../util/assert';
import { getClassNamesFor } from '../util/getClassNamesFor';
type Tab = {
id: string;
label: string;
};
export type TabsOptionsType = {
initialSelectedTab?: string;
moduleClassName?: string;
onTabChange?: (selectedTab: string) => unknown;
tabs: Array<Tab>;
};
export function useTabs({
initialSelectedTab,
moduleClassName,
onTabChange,
tabs,
}: TabsOptionsType): {
selectedTab: string;
tabsHeaderElement: JSX.Element;
} {
assert(tabs.length, 'Tabs needs more than 1 tab present');
const getClassName = getClassNamesFor('Tabs', moduleClassName);
const [selectedTab, setSelectedTab] = useState<string>(
initialSelectedTab || tabs[0].id
);
const tabsHeaderElement = (
<div className={getClassName('')}>
{tabs.map(({ id, label }) => (
<div
className={classNames(
getClassName('__tab'),
selectedTab === id && getClassName('__tab--selected')
)}
key={id}
onClick={() => {
setSelectedTab(id);
onTabChange?.(id);
}}
onKeyUp={(e: KeyboardEvent) => {
if (e.target === e.currentTarget && e.keyCode === 13) {
setSelectedTab(id);
e.preventDefault();
e.stopPropagation();
}
}}
role="tab"
tabIndex={0}
>
{label}
</div>
))}
</div>
);
return {
selectedTab,
tabsHeaderElement,
};
}

View file

@ -141,6 +141,7 @@ export async function sendNormalMessage(
preview,
quote,
sticker,
storyContextTimestamp,
} = await getMessageSendData({ log, message });
let messageSendPromise: Promise<CallbackResultType | void>;
@ -253,6 +254,7 @@ export async function sendNormalMessage(
groupId: undefined,
profileKey,
options: sendOptions,
storyContextTimestamp,
});
}
@ -400,6 +402,7 @@ async function getMessageSendData({
preview: Array<LinkPreviewType>;
quote: WhatIsThis;
sticker: WhatIsThis;
storyContextTimestamp?: number;
}> {
const {
loadAttachmentData,
@ -454,6 +457,7 @@ async function getMessageSendData({
preview,
quote,
sticker,
storyContextTimestamp: message.get('sent_at'),
};
}

View file

@ -220,6 +220,7 @@ export async function sendReaction(
groupId: undefined,
profileKey,
options: sendOptions,
storyContextTimestamp: message.get('sent_at'),
});
} else {
log.info('sending group reaction message');

View file

@ -9,7 +9,7 @@ import type {
QuotedMessageType,
} from '../model-types.d';
import type { UUIDStringType } from '../types/UUID';
import { isIncoming, isOutgoing } from '../state/selectors/message';
import { isIncoming, isOutgoing, isStory } from '../state/selectors/message';
export function isQuoteAMatch(
message: MessageAttributesType | null | undefined,
@ -57,7 +57,7 @@ export function getContact(
}
export function getSource(message: MessageAttributesType): string | undefined {
if (isIncoming(message)) {
if (isIncoming(message) || isStory(message)) {
return message.source;
}
if (!isOutgoing(message)) {
@ -72,7 +72,7 @@ export function getSourceDevice(
): string | number | undefined {
const { sourceDevice } = message;
if (isIncoming(message)) {
if (isIncoming(message) || isStory(message)) {
return sourceDevice;
}
if (!isOutgoing(message)) {
@ -87,7 +87,7 @@ export function getSourceDevice(
export function getSourceUuid(
message: MessageAttributesType
): UUIDStringType | undefined {
if (isIncoming(message)) {
if (isIncoming(message) || isStory(message)) {
return message.sourceUuid;
}
if (!isOutgoing(message)) {

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

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as Backbone from 'backbone';
@ -69,6 +69,8 @@ export type GroupMigrationType = {
invitedMembers: Array<GroupV2PendingMemberType>;
};
export type PreviewMessageType = Array<WhatIsThis>;
export type QuotedMessageType = {
attachments: Array<typeof window.WhatIsThis>;
// `author` is an old attribute that holds the author's E164. We shouldn't use it for
@ -83,6 +85,13 @@ export type QuotedMessageType = {
messageId: string;
};
export type StickerMessageType = {
packId: string;
stickerId: number;
packKey: string;
data?: AttachmentType;
};
export type RetryOptions = Readonly<{
type: 'session-reset';
uuid: string;
@ -164,13 +173,8 @@ export type MessageAttributesType = {
| 'verified-change';
body?: string;
attachments?: Array<AttachmentType>;
preview?: Array<WhatIsThis>;
sticker?: {
packId: string;
stickerId: number;
packKey: string;
data?: AttachmentType;
};
preview?: PreviewMessageType;
sticker?: StickerMessageType;
sent_at: number;
unidentifiedDeliveries?: Array<string>;
contact?: Array<EmbeddedContactType>;
@ -242,6 +246,7 @@ export type ConversationAttributesType = {
draftAttachments?: Array<AttachmentDraftType>;
draftBodyRanges?: Array<BodyRangeType>;
draftTimestamp?: number | null;
hideStory?: boolean;
inbox_position: number;
isPinned: boolean;
lastMessageDeletedForEveryone: boolean;

View file

@ -1810,6 +1810,7 @@ export class ConversationModel extends window.Backbone
groupVersion,
groupId: this.get('groupId'),
groupLink: this.getGroupLink(),
hideStory: Boolean(this.get('hideStory')),
inboxPosition,
isArchived: this.get('isArchived')!,
isBlocked: this.isBlocked(),
@ -3790,10 +3791,12 @@ export class ConversationModel extends window.Backbone
{
dontClearDraft,
sendHQImages,
storyId,
timestamp,
}: {
dontClearDraft?: boolean;
sendHQImages?: boolean;
storyId?: string;
timestamp?: number;
} = {}
): Promise<void> {
@ -3872,6 +3875,7 @@ export class ConversationModel extends window.Backbone
updatedAt: now,
})
),
storyId,
});
if (isDirectConversation(this.attributes)) {
@ -4963,6 +4967,11 @@ export class ConversationModel extends window.Backbone
}
}
toggleHideStories(): void {
this.set({ hideStory: !this.get('hideStory') });
this.captureChange('hideStory');
}
setMuteExpiration(
muteExpiresAt = 0,
{ viaStorageServiceSync = false } = {}

View file

@ -43,11 +43,6 @@ import * as expirationTimer from '../util/expirationTimer';
import type { ReactionType } from '../types/Reactions';
import { UUID, UUIDKind } from '../types/UUID';
import * as reactionUtil from '../reactions/util';
import {
copyStickerToAttachments,
savePackMetadata,
getStickerPackStatus,
} from '../types/Stickers';
import * as Stickers from '../types/Stickers';
import * as Errors from '../types/errors';
import * as EmbeddedContact from '../types/EmbeddedContact';
@ -99,6 +94,7 @@ import {
isKeyChange,
isMessageHistoryUnsynced,
isOutgoing,
isStory,
isProfileChange,
isTapToView,
isUniversalTimerNotification,
@ -124,7 +120,6 @@ import { ReactionSource } from '../reactions/ReactionSource';
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
import * as LinkPreview from '../types/LinkPreview';
import { SignalService as Proto } from '../protobuf';
import {
@ -141,13 +136,18 @@ import {
getContact,
getContactId,
getSource,
getSourceDevice,
getSourceUuid,
isCustomError,
isQuoteAMatch,
} from '../messages/helpers';
import type { ReplacementValuesType } from '../types/I18N';
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
import { getMessageIdForLogging } from '../util/getMessageIdForLogging';
import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads';
import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads';
import { findStoryMessage } from '../util/findStoryMessage';
import { isConversationAccepted } from '../util/isConversationAccepted';
import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
/* eslint-disable camelcase */
@ -165,7 +165,7 @@ window.Whisper = window.Whisper || {};
const { Message: TypedMessage } = window.Signal.Types;
const { upgradeMessageSchema } = window.Signal.Migrations;
const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
const { addStickerPackReference, getMessageBySender } = window.Signal.Data;
const { getMessageBySender } = window.Signal.Data;
export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
static getLongMessageAttachment: (
@ -232,6 +232,33 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// Note: The clone is important for triggering a re-run of selectors
messageChanged(this.id, conversationId, { ...this.attributes });
}
const { addStory } = window.reduxActions.stories;
if (isStory(this.attributes)) {
const ourConversationId =
window.ConversationController.getOurConversationIdOrThrow();
const storyData = getStoryDataFromMessageAttributes(
this.attributes,
ourConversationId
);
if (!storyData) {
return;
}
// TODO DESKTOP-3179
// Only add stories to redux if we've downloaded them. This should work
// because once we download a story we'll receive another change event
// which kicks off this function again.
if (Attachment.hasNotDownloaded(storyData.attachment)) {
return;
}
// This is fine to call multiple times since the addStory action only
// adds new stories.
addStory(storyData);
}
}
getSenderIdentifier(): string {
@ -740,12 +767,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// General
idForLogging(): string {
const account =
getSourceUuid(this.attributes) || getSource(this.attributes);
const device = getSourceDevice(this.attributes);
const timestamp = this.get('sent_at');
return `${account}.${device} ${timestamp}`;
return getMessageIdForLogging(this.attributes);
}
override defaults(): Partial<MessageAttributesType> {
@ -1636,332 +1658,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return getLastChallengeError(this.attributes);
}
// NOTE: If you're modifying this function then you'll likely also need
// to modify queueAttachmentDownloads since it contains the logic below
hasAttachmentDownloads(): boolean {
const attachments = this.get('attachments') || [];
const [longMessageAttachments, normalAttachments] = _.partition(
attachments,
attachment => MIME.isLongMessage(attachment.contentType)
);
if (longMessageAttachments.length > 0) {
return true;
}
const hasNormalAttachments = normalAttachments.some(attachment => {
if (!attachment) {
return false;
}
// We've already downloaded this!
if (attachment.path) {
return false;
}
return true;
});
if (hasNormalAttachments) {
return true;
}
const previews = this.get('preview') || [];
const hasPreviews = previews.some(item => {
if (!item.image) {
return false;
}
// We've already downloaded this!
if (item.image.path) {
return false;
}
return true;
});
if (hasPreviews) {
return true;
}
const contacts = this.get('contact') || [];
const hasContacts = contacts.some(item => {
if (!item.avatar || !item.avatar.avatar) {
return false;
}
if (item.avatar.avatar.path) {
return false;
}
return true;
});
if (hasContacts) {
return true;
}
const quote = this.get('quote');
const quoteAttachments =
quote && quote.attachments ? quote.attachments : [];
const hasQuoteAttachments = quoteAttachments.some(item => {
if (!item.thumbnail) {
return false;
}
// We've already downloaded this!
if (item.thumbnail.path) {
return false;
}
return true;
});
if (hasQuoteAttachments) {
return true;
}
const sticker = this.get('sticker');
if (sticker) {
return !sticker.data || (sticker.data && !sticker.data.path);
}
return false;
return hasAttachmentDownloads(this.attributes);
}
// Receive logic
// NOTE: If you're changing any logic in this function that deals with the
// count then you'll also have to modify the above function
// hasAttachmentDownloads
async queueAttachmentDownloads(): Promise<boolean> {
const attachmentsToQueue = this.get('attachments') || [];
const messageId = this.id;
let count = 0;
let bodyPending;
log.info(
`Queueing ${
attachmentsToQueue.length
} attachment downloads for message ${this.idForLogging()}`
);
const [longMessageAttachments, normalAttachments] = _.partition(
attachmentsToQueue,
attachment => MIME.isLongMessage(attachment.contentType)
);
if (longMessageAttachments.length > 1) {
log.error(
`Received more than one long message attachment in message ${this.idForLogging()}`
);
const value = await queueAttachmentDownloads(this.attributes);
if (!value) {
return false;
}
log.info(
`Queueing ${
longMessageAttachments.length
} long message attachment downloads for message ${this.idForLogging()}`
);
if (longMessageAttachments.length > 0) {
count += 1;
bodyPending = true;
await AttachmentDownloads.addJob(longMessageAttachments[0], {
messageId,
type: 'long-message',
index: 0,
});
}
log.info(
`Queueing ${
normalAttachments.length
} normal attachment downloads for message ${this.idForLogging()}`
);
const attachments = await Promise.all(
normalAttachments.map((attachment, index) => {
if (!attachment) {
return attachment;
}
// We've already downloaded this!
if (attachment.path) {
log.info(
`Normal attachment already downloaded for message ${this.idForLogging()}`
);
return attachment;
}
count += 1;
return AttachmentDownloads.addJob(attachment, {
messageId,
type: 'attachment',
index,
});
})
);
const previewsToQueue = this.get('preview') || [];
log.info(
`Queueing ${
previewsToQueue.length
} preview attachment downloads for message ${this.idForLogging()}`
);
const preview = await Promise.all(
previewsToQueue.map(async (item, index) => {
if (!item.image) {
return item;
}
// We've already downloaded this!
if (item.image.path) {
log.info(
`Preview attachment already downloaded for message ${this.idForLogging()}`
);
return item;
}
count += 1;
return {
...item,
image: await AttachmentDownloads.addJob(item.image, {
messageId,
type: 'preview',
index,
}),
};
})
);
const contactsToQueue = this.get('contact') || [];
log.info(
`Queueing ${
contactsToQueue.length
} contact attachment downloads for message ${this.idForLogging()}`
);
const contact = await Promise.all(
contactsToQueue.map(async (item, index) => {
if (!item.avatar || !item.avatar.avatar) {
return item;
}
// We've already downloaded this!
if (item.avatar.avatar.path) {
log.info(
`Contact attachment already downloaded for message ${this.idForLogging()}`
);
return item;
}
count += 1;
return {
...item,
avatar: {
...item.avatar,
avatar: await AttachmentDownloads.addJob(item.avatar.avatar, {
messageId,
type: 'contact',
index,
}),
},
};
})
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let quote = this.get('quote')!;
const quoteAttachmentsToQueue =
quote && quote.attachments ? quote.attachments : [];
log.info(
`Queueing ${
quoteAttachmentsToQueue.length
} quote attachment downloads for message ${this.idForLogging()}`
);
if (quoteAttachmentsToQueue.length > 0) {
quote = {
...quote,
attachments: await Promise.all(
(quote.attachments || []).map(async (item, index) => {
if (!item.thumbnail) {
return item;
}
// We've already downloaded this!
if (item.thumbnail.path) {
log.info(
`Quote attachment already downloaded for message ${this.idForLogging()}`
);
return item;
}
count += 1;
return {
...item,
thumbnail: await AttachmentDownloads.addJob(item.thumbnail, {
messageId,
type: 'quote',
index,
}),
};
})
),
};
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let sticker = this.get('sticker')!;
if (sticker && sticker.data && sticker.data.path) {
log.info(
`Sticker attachment already downloaded for message ${this.idForLogging()}`
);
} else if (sticker) {
log.info(`Queueing sticker download for message ${this.idForLogging()}`);
count += 1;
const { packId, stickerId, packKey } = sticker;
const status = getStickerPackStatus(packId);
let data: AttachmentType | undefined;
if (status && (status === 'downloaded' || status === 'installed')) {
try {
data = await copyStickerToAttachments(packId, stickerId);
} catch (error) {
log.error(
`Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
error && error.stack ? error.stack : error
);
}
}
if (!data && sticker.data) {
data = await AttachmentDownloads.addJob(sticker.data, {
messageId,
type: 'sticker',
index: 0,
});
}
if (!status) {
// Save the packId/packKey for future download/install
savePackMetadata(packId, packKey, { messageId });
} else {
await addStickerPackReference(messageId, packId);
}
if (!data) {
throw new Error(
'queueAttachmentDownloads: Failed to fetch sticker data'
);
}
sticker = {
...sticker,
packId,
data,
};
}
log.info(
`Queued ${count} total attachment downloads for message ${this.idForLogging()}`
);
if (count > 0) {
this.set({
bodyPending,
attachments,
preview,
contact,
quote,
sticker,
});
return true;
}
return false;
this.set(value);
return true;
}
markAttachmentAsCorrupted(attachment: AttachmentType): void {
@ -2207,6 +1915,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}`
);
if (
type === 'story' &&
!isConversationAccepted(conversation.attributes)
) {
log.info(
'handleDataMessage: dropping story from !whitelisted',
this.getSenderIdentifier()
);
confirm();
return;
}
// First, check for duplicates. If we find one, stop processing here.
const inMemoryMessage = window.MessageController.findBySender(
this.getSenderIdentifier()
@ -2471,12 +2191,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
});
}
const [quote, storyQuote] = await Promise.all([
this.copyFromQuotedMessage(initialMessage.quote, conversation.id),
findStoryMessage(conversation.id, initialMessage.storyContext),
]);
const withQuoteReference = {
...initialMessage,
quote: await this.copyFromQuotedMessage(
initialMessage.quote,
conversation.id
),
quote,
storyId: storyQuote?.id,
};
const dataMessage = await upgradeMessageSchema(withQuoteReference);
@ -2521,6 +2244,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
quote: dataMessage.quote,
schemaVersion: dataMessage.schemaVersion,
sticker: dataMessage.sticker,
storyId: dataMessage.storyId,
});
const isSupported = !isUnsupportedMessage(message.attributes);
@ -2807,8 +2531,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
conversation.incrementMessageCount();
window.Signal.Data.updateConversation(conversation.attributes);
// Only queue attachments for downloads if this is an outgoing message
// or we've accepted the conversation
// 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') || [];
const shouldHoldOffDownload =
@ -2818,6 +2542,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.hasAttachmentDownloads() &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(this.getConversation()!.getAccepted() ||
isStory(message.attributes) ||
isOutgoing(message.attributes)) &&
!shouldHoldOffDownload
) {

View file

@ -129,10 +129,7 @@ function generateStorageID(): Uint8Array {
}
type GeneratedManifestType = {
conversationsToUpdate: Array<{
conversation: ConversationModel;
storageID: string | undefined;
}>;
postUploadUpdateFunctions: Array<() => unknown>;
deleteKeys: Array<Uint8Array>;
newItems: Set<Proto.IStorageItem>;
storageManifest: Proto.IStorageManifest;
@ -152,7 +149,7 @@ async function generateManifest(
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
const conversationsToUpdate = [];
const postUploadUpdateFunctions: Array<() => unknown> = [];
const insertKeys: Array<string> = [];
const deleteKeys: Array<Uint8Array> = [];
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
@ -275,9 +272,13 @@ async function generateManifest(
);
}
conversationsToUpdate.push({
conversation,
storageID,
postUploadUpdateFunctions.push(() => {
conversation.set({
needsStorageServiceSync: false,
storageVersion: version,
storageID,
});
updateConversation(conversation.attributes);
});
}
@ -510,7 +511,7 @@ async function generateManifest(
storageManifest.value = encryptedManifest;
return {
conversationsToUpdate,
postUploadUpdateFunctions,
deleteKeys,
newItems,
storageManifest,
@ -520,7 +521,7 @@ async function generateManifest(
async function uploadManifest(
version: number,
{
conversationsToUpdate,
postUploadUpdateFunctions,
deleteKeys,
newItems,
storageManifest,
@ -556,18 +557,11 @@ async function uploadManifest(
log.info(
`storageService.upload(${version}): upload complete, updating ` +
`conversations=${conversationsToUpdate.length}`
`items=${postUploadUpdateFunctions.length}`
);
// update conversations with the new storageID
conversationsToUpdate.forEach(({ conversation, storageID }) => {
conversation.set({
needsStorageServiceSync: false,
storageVersion: version,
storageID,
});
updateConversation(conversation.attributes);
});
postUploadUpdateFunctions.forEach(fn => fn());
} catch (err) {
log.error(
`storageService.upload(${version}): failed!`,
@ -655,11 +649,11 @@ async function createNewManifest() {
const version = window.storage.get('manifestVersion', 0);
const { conversationsToUpdate, newItems, storageManifest } =
const { postUploadUpdateFunctions, newItems, storageManifest } =
await generateManifest(version, undefined, true);
await uploadManifest(version, {
conversationsToUpdate,
postUploadUpdateFunctions,
// we have created a new manifest, there should be no keys to delete
deleteKeys: [],
newItems,

View file

@ -154,15 +154,18 @@ export async function toContactRecord(
contactRecord.mutedUntilTimestamp = getSafeLongFromTimestamp(
conversation.get('muteExpiresAt')
);
if (conversation.get('hideStory') !== undefined) {
contactRecord.hideStory = Boolean(conversation.get('hideStory'));
}
applyUnknownFields(contactRecord, conversation);
return contactRecord;
}
export async function toAccountRecord(
export function toAccountRecord(
conversation: ConversationModel
): Promise<Proto.AccountRecord> {
): Proto.AccountRecord {
const accountRecord = new Proto.AccountRecord();
if (conversation.get('profileKey')) {
@ -319,9 +322,9 @@ export async function toAccountRecord(
return accountRecord;
}
export async function toGroupV1Record(
export function toGroupV1Record(
conversation: ConversationModel
): Promise<Proto.GroupV1Record> {
): Proto.GroupV1Record {
const groupV1Record = new Proto.GroupV1Record();
groupV1Record.id = Bytes.fromBinary(String(conversation.get('groupId')));
@ -338,9 +341,9 @@ export async function toGroupV1Record(
return groupV1Record;
}
export async function toGroupV2Record(
export function toGroupV2Record(
conversation: ConversationModel
): Promise<Proto.GroupV2Record> {
): Proto.GroupV2Record {
const groupV2Record = new Proto.GroupV2Record();
const masterKey = conversation.get('masterKey');
@ -357,6 +360,7 @@ export async function toGroupV2Record(
groupV2Record.dontNotifyForMentionsIfMuted = Boolean(
conversation.get('dontNotifyForMentionsIfMuted')
);
groupV2Record.hideStory = Boolean(conversation.get('hideStory'));
applyUnknownFields(groupV2Record, conversation);
@ -592,7 +596,7 @@ export async function mergeGroupV1Record(
addUnknownFields(groupV1Record, conversation, details);
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
await toGroupV1Record(conversation),
toGroupV1Record(conversation),
groupV1Record,
conversation
);
@ -683,6 +687,7 @@ export async function mergeGroupV2Record(
const oldStorageVersion = conversation.get('storageVersion');
conversation.set({
hideStory: Boolean(groupV2Record.hideStory),
isArchived: Boolean(groupV2Record.archived),
markedUnread: Boolean(groupV2Record.markedUnread),
dontNotifyForMentionsIfMuted: Boolean(
@ -706,7 +711,7 @@ export async function mergeGroupV2Record(
addUnknownFields(groupV2Record, conversation, details);
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
await toGroupV2Record(conversation),
toGroupV2Record(conversation),
groupV2Record,
conversation
);
@ -852,6 +857,7 @@ export async function mergeContactRecord(
const oldStorageVersion = conversation.get('storageVersion');
conversation.set({
hideStory: Boolean(contactRecord.hideStory),
isArchived: Boolean(contactRecord.archived),
markedUnread: Boolean(contactRecord.markedUnread),
storageID,
@ -1142,7 +1148,7 @@ export async function mergeAccountRecord(
}
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
await toAccountRecord(conversation),
toAccountRecord(conversation),
accountRecord,
conversation
);

View file

@ -0,0 +1,83 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { pick } from 'lodash';
import type { MessageAttributesType } from '../model-types.d';
import type { StoryDataType } from '../state/ducks/stories';
import * as log from '../logging/log';
import dataInterface from '../sql/Client';
import { getAttachmentsForMessage } from '../state/selectors/message';
import { hasNotDownloaded } from '../types/Attachment';
import { isNotNil } from '../util/isNotNil';
import { strictAssert } from '../util/assert';
let storyData: Array<MessageAttributesType> | undefined;
export async function loadStories(): Promise<void> {
storyData = await dataInterface.getOlderStories({});
}
export function getStoryDataFromMessageAttributes(
message: MessageAttributesType,
ourConversationId?: string
): StoryDataType | undefined {
const { attachments } = message;
const unresolvedAttachment = attachments ? attachments[0] : undefined;
if (!unresolvedAttachment) {
log.warn(
`getStoryDataFromMessageAttributes: ${message.id} does not have an attachment`
);
return;
}
// Quickly determine if item hasn't been
// downloaded before we run getAttachmentsForMessage which is cached.
if (!unresolvedAttachment.path) {
log.warn(
`getStoryDataFromMessageAttributes: ${message.id} not downloaded (no path)`
);
return;
}
const [attachment] = getAttachmentsForMessage(message);
// TODO DESKTOP-3179
if (hasNotDownloaded(attachment)) {
log.warn(
`getStoryDataFromMessageAttributes: ${message.id} not downloaded (no url)`
);
return;
}
const selectedReaction = (
(message.reactions || []).find(re => re.fromId === ourConversationId) || {}
).emoji;
return {
attachment,
messageId: message.id,
selectedReaction,
...pick(message, [
'conversationId',
'readStatus',
'source',
'sourceUuid',
'timestamp',
]),
};
}
export function getStoriesForRedux(): Array<StoryDataType> {
strictAssert(storyData, 'storyData has not been loaded');
const ourConversationId =
window.ConversationController.getOurConversationId();
const stories = storyData
.map(story => getStoryDataFromMessageAttributes(story, ourConversationId))
.filter(isNotNil);
storyData = undefined;
return stories;
}

View file

@ -285,6 +285,7 @@ const dataInterface: ClientInterface = {
_deleteAllStoryDistributions,
createNewStoryDistribution,
getAllStoryDistributionsWithMembers,
getStoryDistributionWithMembers,
modifyStoryDistribution,
modifyStoryDistributionMembers,
deleteStoryDistribution,
@ -1583,6 +1584,11 @@ async function getAllStoryDistributionsWithMembers(): Promise<
> {
return channels.getAllStoryDistributionsWithMembers();
}
async function getStoryDistributionWithMembers(
id: string
): Promise<StoryDistributionWithMembersType | undefined> {
return channels.getStoryDistributionWithMembers(id);
}
async function modifyStoryDistribution(
distribution: StoryDistributionType
): Promise<void> {

View file

@ -536,6 +536,9 @@ export type DataInterface = {
getAllStoryDistributionsWithMembers(): Promise<
Array<StoryDistributionWithMembersType>
>;
getStoryDistributionWithMembers(
id: string
): Promise<StoryDistributionWithMembersType | undefined>;
modifyStoryDistribution(distribution: StoryDistributionType): Promise<void>;
modifyStoryDistributionMembers(
id: string,

View file

@ -281,6 +281,7 @@ const dataInterface: ServerInterface = {
_deleteAllStoryDistributions,
createNewStoryDistribution,
getAllStoryDistributionsWithMembers,
getStoryDistributionWithMembers,
modifyStoryDistribution,
modifyStoryDistributionMembers,
deleteStoryDistribution,
@ -3965,6 +3966,33 @@ async function getAllStoryDistributionsWithMembers(): Promise<
members: (byListId[list.id] || []).map(member => member.uuid),
}));
}
async function getStoryDistributionWithMembers(
id: string
): Promise<StoryDistributionWithMembersType | undefined> {
const db = getInstance();
const storyDistribution = prepare(
db,
'SELECT * FROM storyDistributions WHERE id = $id;'
).get({
id,
});
if (!storyDistribution) {
return undefined;
}
const members = prepare(
db,
'SELECT * FROM storyDistributionMembers WHERE listId = $id;'
).all({
id,
});
return {
...storyDistribution,
members: members.map(({ uuid }) => uuid),
};
}
async function modifyStoryDistribution(
distribution: StoryDistributionType
): Promise<void> {

View file

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { actions as accounts } from './ducks/accounts';
@ -19,6 +19,7 @@ import { actions as network } from './ducks/network';
import { actions as safetyNumber } from './ducks/safetyNumber';
import { actions as search } from './ducks/search';
import { actions as stickers } from './ducks/stickers';
import { actions as stories } from './ducks/stories';
import { actions as updates } from './ducks/updates';
import { actions as user } from './ducks/user';
import type { ReduxActions } from './types';
@ -42,6 +43,7 @@ export const actionCreators: ReduxActions = {
safetyNumber,
search,
stickers,
stories,
updates,
user,
};
@ -65,6 +67,7 @@ export const mapDispatchToProps = {
...safetyNumber,
...search,
...stickers,
...stories,
...updates,
...user,
};

View file

@ -78,6 +78,7 @@ import { showToast } from '../../util/showToast';
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchUsername';
import { isValidUsername } from '../../types/Username';
import { useBoundActions } from '../../hooks/useBoundActions';
import type { NoopActionType } from './noop';
import { conversationJobQueue } from '../../jobs/conversationJobQueue';
@ -130,6 +131,7 @@ export type ConversationType = {
customColor?: CustomColorType;
customColorId?: string;
discoveredUnregisteredAt?: number;
hideStory?: boolean;
isArchived?: boolean;
isBlocked?: boolean;
isGroupV1AndDisabled?: boolean;
@ -851,10 +853,14 @@ export const actions = {
toggleAdmin,
toggleConversationInChooseMembers,
toggleComposeEditingAvatar,
toggleHideStories,
updateConversationModelSharedGroups,
verifyConversationsStoppingSend,
};
export const useConversationsActions = (): typeof actions =>
useBoundActions(actions);
function filterAvatarData(
avatars: ReadonlyArray<AvatarDataType>,
data: AvatarDataType
@ -1947,6 +1953,21 @@ function openConversationExternal(
};
}
function toggleHideStories(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return dispatch => {
const conversationModel = window.ConversationController.get(conversationId);
if (conversationModel) {
conversationModel.toggleHideStories();
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function removeMemberFromGroup(
conversationId: string,
contactId: string

278
ts/state/ducks/stories.ts Normal file
View file

@ -0,0 +1,278 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import { pick } from 'lodash';
import type { AttachmentType } from '../../types/Attachment';
import type { BodyRangeType } from '../../types/Util';
import type { MessageAttributesType } from '../../model-types.d';
import type { MessageDeletedActionType } from './conversations';
import type { NoopActionType } from './noop';
import type { StateType as RootStateType } from '../reducer';
import type { StoryViewType } from '../../components/StoryListItem';
import type { SyncType } from '../../jobs/helpers/syncHelpers';
import * as log from '../../logging/log';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { ToastReactionFailed } from '../../components/ToastReactionFailed';
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
import { getMessageById } from '../../messages/getMessageById';
import { markViewed } from '../../services/MessageUpdater';
import { showToast } from '../../util/showToast';
import { useBoundActions } from '../../hooks/useBoundActions';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
export type StoryDataType = {
attachment?: AttachmentType;
messageId: string;
selectedReaction?: string;
} & Pick<
MessageAttributesType,
'conversationId' | 'readStatus' | 'source' | 'sourceUuid' | 'timestamp'
>;
// State
export type StoriesStateType = {
readonly isShowingStoriesView: boolean;
readonly stories: Array<StoryDataType>;
};
// Actions
const ADD_STORY = 'stories/ADD_STORY';
const REACT_TO_STORY = 'stories/REACT_TO_STORY';
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
type AddStoryActionType = {
type: typeof ADD_STORY;
payload: StoryDataType;
};
type ReactToStoryActionType = {
type: typeof REACT_TO_STORY;
payload: {
messageId: string;
selectedReaction: string;
};
};
type ToggleViewActionType = {
type: typeof TOGGLE_VIEW;
};
export type StoriesActionType =
| AddStoryActionType
| MessageDeletedActionType
| ReactToStoryActionType
| ToggleViewActionType;
// Action Creators
export const actions = {
addStory,
markStoryRead,
reactToStory,
replyToStory,
toggleStoriesView,
};
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
function addStory(story: StoryDataType): AddStoryActionType {
return {
type: ADD_STORY,
payload: story,
};
}
function markStoryRead(
messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async (dispatch, getState) => {
const { stories } = getState().stories;
const matchingStory = stories.find(story => story.messageId === messageId);
if (!matchingStory) {
log.warn(`markStoryRead: no matching story found: ${messageId}`);
return;
}
if (matchingStory.readStatus !== ReadStatus.Unread) {
return;
}
const message = await getMessageById(messageId);
if (!message) {
return;
}
markViewed(message.attributes, Date.now());
const viewedReceipt = {
messageId,
senderE164: message.attributes.source,
senderUuid: message.attributes.sourceUuid,
timestamp: message.attributes.sent_at,
};
const viewSyncs: Array<SyncType> = [viewedReceipt];
if (!window.ConversationController.areWePrimaryDevice()) {
viewSyncJobQueue.add({ viewSyncs });
}
viewedReceiptsJobQueue.add({ viewedReceipt });
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function reactToStory(
nextReaction: string,
messageId: string,
previousReaction?: string
): ThunkAction<void, RootStateType, unknown, ReactToStoryActionType> {
return async dispatch => {
try {
await enqueueReactionForSend({
messageId,
emoji: nextReaction,
remove: nextReaction === previousReaction,
});
dispatch({
type: REACT_TO_STORY,
payload: {
messageId,
selectedReaction: nextReaction,
},
});
} catch (error) {
log.error('Error enqueuing reaction', error, messageId, nextReaction);
showToast(ToastReactionFailed);
}
};
}
function replyToStory(
conversationId: string,
message: string,
mentions: Array<BodyRangeType>,
timestamp: number,
story: StoryViewType
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (conversation) {
conversation.enqueueMessageForSend(
message,
[],
undefined,
undefined,
undefined,
mentions,
{
storyId: story.messageId,
timestamp,
}
);
}
return {
type: 'NOOP',
payload: null,
};
}
function toggleStoriesView(): ToggleViewActionType {
return {
type: TOGGLE_VIEW,
};
}
// Reducer
export function getEmptyState(
overrideState: Partial<StoriesStateType> = {}
): StoriesStateType {
return {
isShowingStoriesView: false,
stories: [],
...overrideState,
};
}
export function reducer(
state: Readonly<StoriesStateType> = getEmptyState(),
action: Readonly<StoriesActionType>
): StoriesStateType {
if (action.type === TOGGLE_VIEW) {
return {
...state,
isShowingStoriesView: !state.isShowingStoriesView,
};
}
if (action.type === 'MESSAGE_DELETED') {
return {
...state,
stories: state.stories.filter(
story => story.messageId !== action.payload.id
),
};
}
if (action.type === ADD_STORY) {
const newStory = pick(action.payload, [
'attachment',
'conversationId',
'messageId',
'readStatus',
'selectedReaction',
'source',
'sourceUuid',
'timestamp',
]);
// TODO DEKTOP-3179
// ADD_STORY fires whenever the message model changes so we check if this
// story already exists in state -- if it does then we don't need to re-add.
const hasStory = state.stories.find(
existingStory => existingStory.messageId === newStory.messageId
);
if (hasStory) {
return state;
}
const stories = [newStory, ...state.stories].sort((a, b) =>
a.timestamp > b.timestamp ? -1 : 1
);
return {
...state,
stories,
};
}
if (action.type === REACT_TO_STORY) {
return {
...state,
stories: state.stories.map(story => {
if (story.messageId === action.payload.messageId) {
return {
...story,
selectedReaction: action.payload.selectedReaction,
};
}
return story;
}),
};
}
return state;
}

View file

@ -16,19 +16,23 @@ import { getEmptyState as network } from './ducks/network';
import { getEmptyState as preferredReactions } from './ducks/preferredReactions';
import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
import { getEmptyState as search } from './ducks/search';
import { getEmptyState as getStoriesEmptyState } from './ducks/stories';
import { getEmptyState as updates } from './ducks/updates';
import { getEmptyState as user } from './ducks/user';
import type { StateType } from './reducer';
import type { BadgesStateType } from './ducks/badges';
import type { StoryDataType } from './ducks/stories';
import { getInitialState as stickers } from '../types/Stickers';
import { getEmojiReducerState as emojis } from '../util/loadRecentEmojis';
export function getInitialState({
badges,
stories,
}: {
badges: BadgesStateType;
stories: Array<StoryDataType>;
}): StateType {
const items = window.storage.getItemsState();
@ -87,6 +91,10 @@ export function getInitialState({
safetyNumber: safetyNumber(),
search: search(),
stickers: stickers(),
stories: {
...getStoriesEmptyState(),
stories,
},
updates: updates(),
user: {
...user(),

View file

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { combineReducers } from 'redux';
@ -22,6 +22,7 @@ import { reducer as preferredReactions } from './ducks/preferredReactions';
import { reducer as safetyNumber } from './ducks/safetyNumber';
import { reducer as search } from './ducks/search';
import { reducer as stickers } from './ducks/stickers';
import { reducer as stories } from './ducks/stories';
import { reducer as updates } from './ducks/updates';
import { reducer as user } from './ducks/user';
@ -45,6 +46,7 @@ export const reducer = combineReducers({
safetyNumber,
search,
stickers,
stories,
updates,
user,
});

View file

@ -58,6 +58,13 @@ export const getUsernamesEnabled = createSelector(
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.usernames')
);
export const getStoriesEnabled = createSelector(
getRemoteConfig,
(remoteConfig: ConfigMapType): boolean =>
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.internalUser') ||
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.stories')
);
export const getDefaultConversationColor = createSelector(
getItems,
(

View file

@ -129,6 +129,12 @@ export function isOutgoing(
return message.type === 'outgoing';
}
export function isStory(
message: Pick<MessageWithUIFieldsType, 'type'>
): boolean {
return message.type === 'story';
}
export function hasErrors(
message: Pick<MessageWithUIFieldsType, 'errors'>
): boolean {
@ -1502,7 +1508,9 @@ function canReplyOrReact(
);
}
if (isIncoming(message)) {
// If we get past all the other checks above then we can always reply or
// react if the message type is "incoming" | "story"
if (isIncoming(message) || isStory(message)) {
return true;
}

View file

@ -0,0 +1,98 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import { pick } from 'lodash';
import type {
ConversationStoryType,
StoryViewType,
} from '../../components/StoryListItem';
import type { StateType } from '../reducer';
import type { StoriesStateType } from '../ducks/stories';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { getConversationSelector } from './conversations';
export const getStoriesState = (state: StateType): StoriesStateType =>
state.stories;
export const shouldShowStoriesView = createSelector(
getStoriesState,
({ isShowingStoriesView }): boolean => isShowingStoriesView
);
export const getStories = createSelector(
getConversationSelector,
getStoriesState,
(
conversationSelector,
{ stories }: Readonly<StoriesStateType>
): {
hiddenStories: Array<ConversationStoryType>;
stories: Array<ConversationStoryType>;
} => {
const storiesById = new Map<string, ConversationStoryType>();
const hiddenStoriesById = new Map<string, ConversationStoryType>();
stories.forEach(story => {
const sender = pick(
conversationSelector(story.sourceUuid || story.source),
[
'acceptedMessageRequest',
'avatarPath',
'color',
'firstName',
'hideStory',
'id',
'isMe',
'name',
'profileName',
'sharedGroupNames',
'title',
]
);
const conversation = pick(conversationSelector(story.conversationId), [
'id',
'title',
]);
const { attachment, timestamp } = pick(story, [
'attachment',
'timestamp',
]);
let storiesMap: Map<string, ConversationStoryType>;
if (sender.hideStory) {
storiesMap = hiddenStoriesById;
} else {
storiesMap = storiesById;
}
const storyView: StoryViewType = {
attachment,
isUnread: story.readStatus === ReadStatus.Unread,
messageId: story.messageId,
selectedReaction: story.selectedReaction,
sender,
timestamp,
};
const conversationStory = storiesMap.get(conversation.id) || {
conversationId: conversation.id,
group: conversation.id !== sender.id ? conversation : undefined,
isHidden: Boolean(sender.hideStory),
stories: [],
};
storiesMap.set(conversation.id, {
...conversationStory,
stories: [...conversationStory.stories, storyView],
});
});
return {
hiddenStories: Array.from(hiddenStoriesById.values()),
stories: Array.from(storiesById.values()),
};
}
);

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -9,9 +9,11 @@ import { SmartCallManager } from './CallManager';
import { SmartCustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal';
import { SmartGlobalModalContainer } from './GlobalModalContainer';
import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
import { SmartStories } from './Stories';
import type { StateType } from '../reducer';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { getIntl, getTheme } from '../selectors/user';
import { shouldShowStoriesView } from '../selectors/stories';
import { getConversationsStoppingSend } from '../selectors/conversations';
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
import { mapDispatchToProps } from '../actions';
@ -32,6 +34,8 @@ const mapStateToProps = (state: StateType) => {
renderSafetyNumber: (props: SafetyNumberProps) => (
<SmartSafetyNumberViewer {...props} />
),
isShowingStoriesView: shouldShowStoriesView(state),
renderStories: () => <SmartStories />,
requestVerification: (
type: 'sms' | 'voice',
number: string,

View file

@ -17,11 +17,13 @@ import {
getUserUuid,
} from '../selectors/user';
import { getMe } from '../selectors/conversations';
import { getStoriesEnabled } from '../selectors/items';
const mapStateToProps = (state: StateType) => {
const me = getMe(state);
return {
areStoriesEnabled: getStoriesEnabled(state),
hasPendingUpdate: Boolean(state.updates.didSnooze),
regionCode: getRegionCode(state),
ourConversationId: getUserConversationId(state),

View file

@ -0,0 +1,69 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { useSelector } from 'react-redux';
import type { LocalizerType } from '../../types/Util';
import type { StateType } from '../reducer';
import type { PropsType as SmartStoryViewerPropsType } from './StoryViewer';
import { SmartStoryViewer } from './StoryViewer';
import { Stories } from '../../components/Stories';
import { getIntl } from '../selectors/user';
import { getPreferredLeftPaneWidth } from '../selectors/items';
import { getStories } from '../selectors/stories';
import { useStoriesActions } from '../ducks/stories';
import { useConversationsActions } from '../ducks/conversations';
function renderStoryViewer({
conversationId,
onClose,
onNextUserStories,
onPrevUserStories,
stories,
}: SmartStoryViewerPropsType): JSX.Element {
return (
<SmartStoryViewer
conversationId={conversationId}
onClose={onClose}
onNextUserStories={onNextUserStories}
onPrevUserStories={onPrevUserStories}
stories={stories}
/>
);
}
export function SmartStories(): JSX.Element | null {
const storiesActions = useStoriesActions();
const { openConversationInternal, toggleHideStories } =
useConversationsActions();
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const isShowingStoriesView = useSelector<StateType, boolean>(
(state: StateType) => state.stories.isShowingStoriesView
);
const preferredWidthFromStorage = useSelector<StateType, number>(
getPreferredLeftPaneWidth
);
const { hiddenStories, stories } = useSelector(getStories);
if (!isShowingStoriesView) {
return null;
}
return (
<Stories
hiddenStories={hiddenStories}
i18n={i18n}
openConversationInternal={openConversationInternal}
preferredWidthFromStorage={preferredWidthFromStorage}
renderStoryViewer={renderStoryViewer}
stories={stories}
toggleHideStories={toggleHideStories}
{...storiesActions}
/>
);
}

View file

@ -0,0 +1,84 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { useSelector } from 'react-redux';
import type { LocalizerType } from '../../types/Util';
import type { StateType } from '../reducer';
import type { StoryViewType } from '../../components/StoryListItem';
import { StoryViewer } from '../../components/StoryViewer';
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
import {
getEmojiSkinTone,
getPreferredReactionEmoji,
} from '../selectors/items';
import { getIntl } from '../selectors/user';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { renderEmojiPicker } from './renderEmojiPicker';
import { showToast } from '../../util/showToast';
import { useActions as useEmojisActions } from '../ducks/emojis';
import { useActions as useItemsActions } from '../ducks/items';
import { useRecentEmojis } from '../selectors/emojis';
import { useStoriesActions } from '../ducks/stories';
export type PropsType = {
conversationId: string;
onClose: () => unknown;
onNextUserStories: () => unknown;
onPrevUserStories: () => unknown;
stories: Array<StoryViewType>;
};
export function SmartStoryViewer({
conversationId,
onClose,
onNextUserStories,
onPrevUserStories,
stories,
}: PropsType): JSX.Element | null {
const storiesActions = useStoriesActions();
const { onSetSkinTone } = useItemsActions();
const { onUseEmoji } = useEmojisActions();
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const preferredReactionEmoji = useSelector<StateType, Array<string>>(
getPreferredReactionEmoji
);
const recentEmojis = useRecentEmojis();
const skinTone = useSelector<StateType, number>(getEmojiSkinTone);
return (
<StoryViewer
getPreferredBadge={getPreferredBadge}
i18n={i18n}
onClose={onClose}
onNextUserStories={onNextUserStories}
onPrevUserStories={onPrevUserStories}
onReactToStory={async (emoji, story) => {
const { messageId, selectedReaction: previousReaction } = story;
storiesActions.reactToStory(emoji, messageId, previousReaction);
}}
onReplyToStory={(message, mentions, timestamp, story) => {
storiesActions.replyToStory(
conversationId,
message,
mentions,
timestamp,
story
);
}}
onSetSkinTone={onSetSkinTone}
onTextTooLong={() => showToast(ToastMessageBodyTooLong)}
onUseEmoji={onUseEmoji}
preferredReactionEmoji={preferredReactionEmoji}
recentEmojis={recentEmojis}
renderEmojiPicker={renderEmojiPicker}
stories={stories}
skinTone={skinTone}
{...storiesActions}
/>
);
}

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { actions as accounts } from './ducks/accounts';
@ -19,6 +19,7 @@ import type { actions as network } from './ducks/network';
import type { actions as safetyNumber } from './ducks/safetyNumber';
import type { actions as search } from './ducks/search';
import type { actions as stickers } from './ducks/stickers';
import type { actions as stories } from './ducks/stories';
import type { actions as updates } from './ducks/updates';
import type { actions as user } from './ducks/user';
@ -41,6 +42,7 @@ export type ReduxActions = {
safetyNumber: typeof safetyNumber;
search: typeof search;
stickers: typeof stickers;
stories: typeof stories;
updates: typeof updates;
user: typeof user;
};

View file

@ -1,9 +1,10 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type {
AttachmentType,
AttachmentDraftType,
ThumbnailType,
} from '../../types/Attachment';
import { IMAGE_JPEG } from '../../types/MIME';
@ -17,6 +18,14 @@ export const fakeAttachment = (
...overrides,
});
export const fakeThumbnail = (url: string): ThumbnailType => ({
contentType: IMAGE_JPEG,
height: 100,
path: url,
url,
width: 100,
});
export const fakeDraftAttachment = (
overrides: Partial<AttachmentDraftType> = {}
): AttachmentDraftType => ({

View file

@ -317,6 +317,13 @@ const LAST_NAMES = [
export const getFirstName = (): string => sample(FIRST_NAMES) || 'Test';
export const getLastName = (): string => sample(LAST_NAMES) || 'Test';
export const getAvatarPath = (): string =>
sample([
'/fixtures/kitten-1-64-64.jpg',
'/fixtures/kitten-2-64-64.jpg',
'/fixtures/kitten-3-64-64.jpg',
]) || '';
export function getDefaultConversation(
overrideProps: Partial<ConversationType> = {}
): ConversationType {
@ -325,6 +332,7 @@ export function getDefaultConversation(
return {
acceptedMessageRequest: true,
avatarPath: getAvatarPath(),
badges: [],
e164: '+1300555000',
color: getRandomColor(),

View file

@ -72,6 +72,7 @@ import type { Storage } from './Storage';
import { WarnOnlyError } from './Errors';
import * as Bytes from '../Bytes';
import type {
ProcessedAttachment,
ProcessedDataMessage,
ProcessedSyncMessage,
ProcessedSent,
@ -107,6 +108,7 @@ import {
GroupSyncEvent,
} from './messageReceiverEvents';
import * as log from '../logging/log';
import * as durations from '../util/durations';
import { areArraysMatchingSets } from '../util/areArraysMatchingSets';
const GROUPV1_ID_LENGTH = 16;
@ -1787,6 +1789,66 @@ export default class MessageReceiver
return this.dispatchAndWait(ev);
}
private async handleStoryMessage(
envelope: UnsealedEnvelope,
msg: Proto.IStoryMessage
): Promise<void> {
const logId = this.getEnvelopeId(envelope);
log.info('MessageReceiver.handleStoryMessage', logId);
const attachments: Array<ProcessedAttachment> = [];
if (msg.fileAttachment) {
const attachment = processAttachment(msg.fileAttachment);
attachments.push(attachment);
}
if (msg.textAttachment) {
log.error(
'MessageReceiver.handleStoryMessage: Got a textAttachment, cannot handle it',
logId
);
return;
}
const expireTimer = envelope.timestamp + durations.DAY - Date.now();
if (expireTimer <= 0) {
log.info(
'MessageReceiver.handleStoryMessage: story already expired',
logId
);
this.removeFromCache(envelope);
return;
}
const ev = new MessageEvent(
{
source: envelope.source,
sourceUuid: envelope.sourceUuid,
sourceDevice: envelope.sourceDevice,
timestamp: envelope.timestamp,
serverGuid: envelope.serverGuid,
serverTimestamp: envelope.serverTimestamp,
unidentifiedDeliveryReceived: Boolean(
envelope.unidentifiedDeliveryReceived
),
message: {
attachments,
expireTimer,
flags: 0,
isStory: true,
isViewOnce: false,
timestamp: envelope.timestamp,
},
receivedAtCounter: envelope.receivedAtCounter,
receivedAtDate: envelope.receivedAtDate,
},
this.removeFromCache.bind(this, envelope)
);
return this.dispatchAndWait(ev);
}
private async handleDataMessage(
envelope: UnsealedEnvelope,
msg: Proto.IDataMessage
@ -1794,14 +1856,6 @@ export default class MessageReceiver
const logId = this.getEnvelopeId(envelope);
log.info('MessageReceiver.handleDataMessage', logId);
if (msg.storyContext) {
log.info(
`MessageReceiver.handleDataMessage/${logId}: Dropping incoming dataMessage with storyContext field`
);
this.removeFromCache(envelope);
return undefined;
}
let p: Promise<void> = Promise.resolve();
// eslint-disable-next-line no-bitwise
const destination = envelope.sourceUuid;
@ -1993,11 +2047,7 @@ export default class MessageReceiver
return;
}
if (content.storyMessage) {
const logId = this.getEnvelopeId(envelope);
log.info(
`innerHandleContentMessage/${logId}: Dropping incoming message with storyMessage field`
);
this.removeFromCache(envelope);
await this.handleStoryMessage(envelope, content.storyMessage);
return;
}

View file

@ -191,6 +191,7 @@ export type MessageOptionsType = {
timestamp: number;
mentions?: BodyRangesType;
groupCallUpdate?: GroupCallUpdateType;
storyContextTimestamp?: number;
};
export type GroupSendOptionsType = {
attachments?: Array<AttachmentType>;
@ -208,6 +209,7 @@ export type GroupSendOptionsType = {
timestamp: number;
mentions?: BodyRangesType;
groupCallUpdate?: GroupCallUpdateType;
storyContextTimestamp?: number;
};
class Message {
@ -252,6 +254,8 @@ class Message {
groupCallUpdate?: GroupCallUpdateType;
storyContextTimestamp?: number;
constructor(options: MessageOptionsType) {
this.attachments = options.attachments || [];
this.body = options.body;
@ -270,6 +274,7 @@ class Message {
this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp;
this.mentions = options.mentions;
this.groupCallUpdate = options.groupCallUpdate;
this.storyContextTimestamp = options.storyContextTimestamp;
if (!(this.recipients instanceof Array)) {
throw new Error('Invalid recipient list');
@ -470,6 +475,18 @@ class Message {
proto.groupCallUpdate = groupCallUpdate;
}
if (this.storyContextTimestamp) {
const { StoryContext } = Proto.DataMessage;
const storyContext = new StoryContext();
storyContext.authorUuid = String(
window.textsecure.storage.user.getCheckedUuid()
);
storyContext.sentTimestamp = this.storyContextTimestamp;
proto.storyContext = storyContext;
}
this.dataMessage = proto;
return proto;
}
@ -779,6 +796,7 @@ export default class MessageSender {
quote,
reaction,
sticker,
storyContextTimestamp,
timestamp,
} = options;
@ -833,6 +851,7 @@ export default class MessageSender {
reaction,
recipients,
sticker,
storyContextTimestamp,
timestamp,
};
}
@ -1024,6 +1043,7 @@ export default class MessageSender {
groupId,
profileKey,
options,
storyContextTimestamp,
}: Readonly<{
identifier: string;
messageText: string | undefined;
@ -1038,6 +1058,7 @@ export default class MessageSender {
contentHint: number;
groupId: string | undefined;
profileKey?: Uint8Array;
storyContextTimestamp?: number;
options?: SendOptionsType;
}>): Promise<CallbackResultType> {
return this.sendMessage({
@ -1053,6 +1074,7 @@ export default class MessageSender {
deletedForEveryoneTimestamp,
expireTimer,
profileKey,
storyContextTimestamp,
},
contentHint,
groupId,

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { SignalService as Proto } from '../protobuf';
@ -183,6 +183,8 @@ export type ProcessedBodyRange = Proto.DataMessage.IBodyRange;
export type ProcessedGroupCallUpdate = Proto.DataMessage.IGroupCallUpdate;
export type ProcessedStoryContext = Proto.DataMessage.IStoryContext;
export type ProcessedDataMessage = {
body?: string;
attachments: ReadonlyArray<ProcessedAttachment>;
@ -197,11 +199,13 @@ export type ProcessedDataMessage = {
preview?: ReadonlyArray<ProcessedPreview>;
sticker?: ProcessedSticker;
requiredProtocolVersion?: number;
isStory?: boolean;
isViewOnce: boolean;
reaction?: ProcessedReaction;
delete?: ProcessedDelete;
bodyRanges?: ReadonlyArray<ProcessedBodyRange>;
groupCallUpdate?: ProcessedGroupCallUpdate;
storyContext?: ProcessedStoryContext;
};
export type ProcessedUnidentifiedDeliveryStatus = Omit<

View file

@ -661,6 +661,7 @@ export type CapabilitiesType = {
'gv1-migration': boolean;
senderKey: boolean;
changeNumber: boolean;
stories: boolean;
};
export type CapabilitiesUploadType = {
announcementGroup: true;
@ -668,6 +669,7 @@ export type CapabilitiesUploadType = {
'gv1-migration': true;
senderKey: true;
changeNumber: true;
stories: true;
};
type StickerPackManifestType = Uint8Array;
@ -1726,6 +1728,7 @@ export function initialize({
'gv1-migration': true,
senderKey: true,
changeNumber: true,
stories: true,
};
const { accessKey } = options;

View file

@ -280,6 +280,7 @@ export async function processDataMessage(
delete: processDelete(message.delete),
bodyRanges: message.bodyRanges ?? [],
groupCallUpdate: dropNull(message.groupCallUpdate),
storyContext: dropNull(message.storyContext),
};
const isEndSession = Boolean(result.flags & FLAGS.END_SESSION);

View file

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export const AvatarColorMap = new Map([
@ -184,3 +184,7 @@ export type CustomColorsItemType = {
readonly colors: Record<string, CustomColorType>;
readonly version: number;
};
export function getAvatarColor(color?: AvatarColorType): AvatarColorType {
return color || AvatarColors[0];
}

View file

@ -0,0 +1,72 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types.d';
import type { MessageModel } from '../models/messages';
import type { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log';
import { find } from './iterables';
import { getContactId } from '../messages/helpers';
import { getTimestampFromLong } from './timestampLongUtils';
export async function findStoryMessage(
conversationId: string,
storyContext?: Proto.DataMessage.IStoryContext
): Promise<MessageModel | undefined> {
if (!storyContext) {
return;
}
const { authorUuid, sentTimestamp } = storyContext;
if (!authorUuid || !sentTimestamp) {
return;
}
const sentAt = getTimestampFromLong(sentTimestamp);
const inMemoryMessages = window.MessageController.filterBySentAt(sentAt);
const matchingMessage = find(inMemoryMessages, item =>
isStoryAMatch(item.attributes, conversationId, authorUuid, sentAt)
);
if (matchingMessage) {
return matchingMessage;
}
log.info('findStoryMessage: db lookup needed', sentAt);
const messages = await window.Signal.Data.getMessagesBySentAt(sentAt);
const found = messages.find(item =>
isStoryAMatch(item, conversationId, authorUuid, sentAt)
);
if (!found) {
log.info('findStoryMessage: message not found', sentAt);
return;
}
const message = window.MessageController.register(found.id, found);
return message;
}
export function isStoryAMatch(
message: MessageAttributesType | null | undefined,
conversationId: string,
authorUuid: string,
sentTimestamp: number
): message is MessageAttributesType {
if (!message) {
return false;
}
const authorConversationId = window.ConversationController.ensureContactIds({
e164: undefined,
uuid: authorUuid,
});
return (
message.sent_at === sentTimestamp &&
message.conversationId === conversationId &&
getContactId(message) === authorConversationId
);
}

View file

@ -0,0 +1,13 @@
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types.d';
import { getSource, getSourceDevice, getSourceUuid } from '../messages/helpers';
export function getMessageIdForLogging(message: MessageAttributesType): string {
const account = getSourceUuid(message) || getSource(message);
const device = getSourceDevice(message);
const timestamp = message.sent_at;
return `${account}.${device} ${timestamp}`;
}

View file

@ -0,0 +1,89 @@
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { partition } from 'lodash';
import type { MessageAttributesType } from '../model-types.d';
import { isLongMessage } from '../types/MIME';
// NOTE: If you're modifying this function then you'll likely also need
// to modify ./queueAttachmentDownloads
export function hasAttachmentDownloads(
message: MessageAttributesType
): boolean {
const attachments = message.attachments || [];
const [longMessageAttachments, normalAttachments] = partition(
attachments,
attachment => isLongMessage(attachment.contentType)
);
if (longMessageAttachments.length > 0) {
return true;
}
const hasNormalAttachments = normalAttachments.some(attachment => {
if (!attachment) {
return false;
}
// We've already downloaded this!
if (attachment.path) {
return false;
}
return true;
});
if (hasNormalAttachments) {
return true;
}
const previews = message.preview || [];
const hasPreviews = previews.some(item => {
if (!item.image) {
return false;
}
// We've already downloaded this!
if (item.image.path) {
return false;
}
return true;
});
if (hasPreviews) {
return true;
}
const contacts = message.contact || [];
const hasContacts = contacts.some(item => {
if (!item.avatar || !item.avatar.avatar) {
return false;
}
if (item.avatar.avatar.path) {
return false;
}
return true;
});
if (hasContacts) {
return true;
}
const { quote } = message;
const quoteAttachments = quote && quote.attachments ? quote.attachments : [];
const hasQuoteAttachments = quoteAttachments.some(item => {
if (!item.thumbnail) {
return false;
}
// We've already downloaded this!
if (item.thumbnail.path) {
return false;
}
return true;
});
if (hasQuoteAttachments) {
return true;
}
const { sticker } = message;
if (sticker) {
return !sticker.data || (sticker.data && !sticker.data.path);
}
return false;
}

28
ts/util/leftPaneWidth.ts Normal file
View file

@ -0,0 +1,28 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { clamp } from 'lodash';
import { isSorted } from './isSorted';
import { strictAssert } from './assert';
export const MIN_WIDTH = 97;
export const SNAP_WIDTH = 200;
export const MIN_FULL_WIDTH = 280;
export const MAX_WIDTH = 380;
strictAssert(
isSorted([MIN_WIDTH, SNAP_WIDTH, MIN_FULL_WIDTH, MAX_WIDTH]),
'Expected widths to be in the right order'
);
export function getWidthFromPreferredWidth(
preferredWidth: number,
{ requiresFullWidth }: { requiresFullWidth: boolean }
): number {
const clampedWidth = clamp(preferredWidth, MIN_WIDTH, MAX_WIDTH);
if (requiresFullWidth || clampedWidth >= SNAP_WIDTH) {
return Math.max(clampedWidth, MIN_FULL_WIDTH);
}
return MIN_WIDTH;
}

View file

@ -7639,6 +7639,13 @@
"reasonCategory": "usageTrusted",
"updated": "2021-11-30T10:15:33.662Z"
},
{
"rule": "React-useRef",
"path": "ts/components/StoryViewsNRepliesModal.tsx",
"line": " const inputApiRef = React.useRef<InputApi | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2022-02-15T17:57:06.507Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Tooltip.tsx",

View file

@ -0,0 +1,262 @@
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { partition } from 'lodash';
import type { AttachmentType } from '../types/Attachment';
import type { EmbeddedContactType } from '../types/EmbeddedContact';
import type {
MessageAttributesType,
PreviewMessageType,
QuotedMessageType,
StickerMessageType,
} from '../model-types.d';
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
import * as log from '../logging/log';
import { isLongMessage } from '../types/MIME';
import { getMessageIdForLogging } from './getMessageIdForLogging';
import {
copyStickerToAttachments,
savePackMetadata,
getStickerPackStatus,
} from '../types/Stickers';
import dataInterface from '../sql/Client';
type ReturnType = {
bodyPending?: boolean;
attachments: Array<AttachmentType>;
preview: PreviewMessageType;
contact: Array<EmbeddedContactType>;
quote?: QuotedMessageType;
sticker?: StickerMessageType;
};
// Receive logic
// NOTE: If you're changing any logic in this function that deals with the
// count then you'll also have to modify ./hasAttachmentsDownloads
export async function queueAttachmentDownloads(
message: MessageAttributesType
): Promise<ReturnType | undefined> {
const attachmentsToQueue = message.attachments || [];
const messageId = message.id;
const idForLogging = getMessageIdForLogging(message);
let count = 0;
let bodyPending;
log.info(
`Queueing ${attachmentsToQueue.length} attachment downloads for message ${idForLogging}`
);
const [longMessageAttachments, normalAttachments] = partition(
attachmentsToQueue,
attachment => isLongMessage(attachment.contentType)
);
if (longMessageAttachments.length > 1) {
log.error(
`Received more than one long message attachment in message ${idForLogging}`
);
}
log.info(
`Queueing ${longMessageAttachments.length} long message attachment downloads for message ${idForLogging}`
);
if (longMessageAttachments.length > 0) {
count += 1;
bodyPending = true;
await AttachmentDownloads.addJob(longMessageAttachments[0], {
messageId,
type: 'long-message',
index: 0,
});
}
log.info(
`Queueing ${normalAttachments.length} normal attachment downloads for message ${idForLogging}`
);
const attachments = await Promise.all(
normalAttachments.map((attachment, index) => {
if (!attachment) {
return attachment;
}
// We've already downloaded this!
if (attachment.path) {
log.info(
`Normal attachment already downloaded for message ${idForLogging}`
);
return attachment;
}
count += 1;
return AttachmentDownloads.addJob(attachment, {
messageId,
type: 'attachment',
index,
});
})
);
const previewsToQueue = message.preview || [];
log.info(
`Queueing ${previewsToQueue.length} preview attachment downloads for message ${idForLogging}`
);
const preview = await Promise.all(
previewsToQueue.map(async (item, index) => {
if (!item.image) {
return item;
}
// We've already downloaded this!
if (item.image.path) {
log.info(
`Preview attachment already downloaded for message ${idForLogging}`
);
return item;
}
count += 1;
return {
...item,
image: await AttachmentDownloads.addJob(item.image, {
messageId,
type: 'preview',
index,
}),
};
})
);
const contactsToQueue = message.contact || [];
log.info(
`Queueing ${contactsToQueue.length} contact attachment downloads for message ${idForLogging}`
);
const contact = await Promise.all(
contactsToQueue.map(async (item, index) => {
if (!item.avatar || !item.avatar.avatar) {
return item;
}
// We've already downloaded this!
if (item.avatar.avatar.path) {
log.info(
`Contact attachment already downloaded for message ${idForLogging}`
);
return item;
}
count += 1;
return {
...item,
avatar: {
...item.avatar,
avatar: await AttachmentDownloads.addJob(item.avatar.avatar, {
messageId,
type: 'contact',
index,
}),
},
};
})
);
let { quote } = message;
const quoteAttachmentsToQueue =
quote && quote.attachments ? quote.attachments : [];
log.info(
`Queueing ${quoteAttachmentsToQueue.length} quote attachment downloads for message ${idForLogging}`
);
if (quote && quoteAttachmentsToQueue.length > 0) {
quote = {
...quote,
attachments: await Promise.all(
(quote?.attachments || []).map(async (item, index) => {
if (!item.thumbnail) {
return item;
}
// We've already downloaded this!
if (item.thumbnail.path) {
log.info(
`Quote attachment already downloaded for message ${idForLogging}`
);
return item;
}
count += 1;
return {
...item,
thumbnail: await AttachmentDownloads.addJob(item.thumbnail, {
messageId,
type: 'quote',
index,
}),
};
})
),
};
}
let { sticker } = message;
if (sticker && sticker.data && sticker.data.path) {
log.info(
`Sticker attachment already downloaded for message ${idForLogging}`
);
} else if (sticker) {
log.info(`Queueing sticker download for message ${idForLogging}`);
count += 1;
const { packId, stickerId, packKey } = sticker;
const status = getStickerPackStatus(packId);
let data: AttachmentType | undefined;
if (status && (status === 'downloaded' || status === 'installed')) {
try {
data = await copyStickerToAttachments(packId, stickerId);
} catch (error) {
log.error(
`Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
error && error.stack ? error.stack : error
);
}
}
if (!data && sticker.data) {
data = await AttachmentDownloads.addJob(sticker.data, {
messageId,
type: 'sticker',
index: 0,
});
}
if (!status) {
// Save the packId/packKey for future download/install
savePackMetadata(packId, packKey, { messageId });
} else {
await dataInterface.addStickerPackReference(messageId, packId);
}
if (!data) {
throw new Error('queueAttachmentDownloads: Failed to fetch sticker data');
}
sticker = {
...sticker,
packId,
data,
};
}
log.info(
`Queued ${count} total attachment downloads for message ${idForLogging}`
);
if (count <= 0) {
return;
}
return {
bodyPending,
attachments,
preview,
contact,
quote,
sticker,
};
}