Process incoming story messages
This commit is contained in:
parent
df7cdfacc7
commit
eb91eb6fec
84 changed files with 4382 additions and 652 deletions
|
@ -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$ won’t 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"
|
||||
|
|
1
images/icons/v2/add-reaction-outline-24.svg
Normal file
1
images/icons/v2/add-reaction-outline-24.svg
Normal 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 |
1
images/icons/v2/messages-solid-20.svg
Normal file
1
images/icons/v2/messages-solid-20.svg
Normal 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 |
1
images/icons/v2/open-24.svg
Normal file
1
images/icons/v2/open-24.svg
Normal 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 |
1
images/icons/v2/stories-outline-24.svg
Normal file
1
images/icons/v2/stories-outline-24.svg
Normal 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 |
1
images/icons/v2/stories-outline-56.svg
Normal file
1
images/icons/v2/stories-outline-56.svg
Normal 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 |
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
111
stylesheets/components/MyStories.scss
Normal file
111
stylesheets/components/MyStories.scss
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
134
stylesheets/components/Stories.scss
Normal file
134
stylesheets/components/Stories.scss
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
106
stylesheets/components/StoryListItem.scss
Normal file
106
stylesheets/components/StoryListItem.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
113
stylesheets/components/StoryViewer.scss
Normal file
113
stylesheets/components/StoryViewer.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
152
stylesheets/components/StoryViewsNRepliesModal.scss
Normal file
152
stylesheets/components/StoryViewsNRepliesModal.scss
Normal 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;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -24,6 +24,7 @@ export type ConfigKeyType =
|
|||
| 'desktop.sendSenderKey3'
|
||||
| 'desktop.showUserBadges.beta'
|
||||
| 'desktop.showUserBadges2'
|
||||
| 'desktop.stories'
|
||||
| 'desktop.usernames'
|
||||
| 'global.calling.maxGroupCallRingSize'
|
||||
| 'global.groupsv2.groupSizeHardLimit'
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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()} />;
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 />
|
||||
));
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
124
ts/components/Stories.stories.tsx
Normal file
124
ts/components/Stories.stories.tsx
Normal 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
112
ts/components/Stories.tsx
Normal 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>
|
||||
);
|
||||
};
|
124
ts/components/StoriesPane.tsx
Normal file
124
ts/components/StoriesPane.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
77
ts/components/StoryListItem.stories.tsx
Normal file
77
ts/components/StoryListItem.stories.tsx
Normal 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(),
|
||||
}}
|
||||
/>
|
||||
));
|
240
ts/components/StoryListItem.tsx
Normal file
240
ts/components/StoryListItem.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
121
ts/components/StoryViewer.stories.tsx
Normal file
121
ts/components/StoryViewer.stories.tsx
Normal 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(),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
337
ts/components/StoryViewer.tsx
Normal file
337
ts/components/StoryViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
125
ts/components/StoryViewsNRepliesModal.stories.tsx
Normal file
125
ts/components/StoryViewsNRepliesModal.stories.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
388
ts/components/StoryViewsNRepliesModal.tsx
Normal file
388
ts/components/StoryViewsNRepliesModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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 })}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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
72
ts/hooks/useTabs.tsx
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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
21
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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 } = {}
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
83
ts/services/storyLoader.ts
Normal file
83
ts/services/storyLoader.ts
Normal 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;
|
||||
}
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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
278
ts/state/ducks/stories.ts
Normal 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;
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
98
ts/state/selectors/stories.ts
Normal file
98
ts/state/selectors/stories.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
);
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
69
ts/state/smart/Stories.tsx
Normal file
69
ts/state/smart/Stories.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
84
ts/state/smart/StoryViewer.tsx
Normal file
84
ts/state/smart/StoryViewer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 => ({
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
6
ts/textsecure/Types.d.ts
vendored
6
ts/textsecure/Types.d.ts
vendored
|
@ -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<
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
72
ts/util/findStoryMessage.ts
Normal file
72
ts/util/findStoryMessage.ts
Normal 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
|
||||
);
|
||||
}
|
13
ts/util/getMessageIdForLogging.ts
Normal file
13
ts/util/getMessageIdForLogging.ts
Normal 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}`;
|
||||
}
|
89
ts/util/hasAttachmentDownloads.ts
Normal file
89
ts/util/hasAttachmentDownloads.ts
Normal 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
28
ts/util/leftPaneWidth.ts
Normal 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;
|
||||
}
|
|
@ -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",
|
||||
|
|
262
ts/util/queueAttachmentDownloads.ts
Normal file
262
ts/util/queueAttachmentDownloads.ts
Normal 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,
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue