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": {
|
"accept": {
|
||||||
"message": "Accept"
|
"message": "Accept"
|
||||||
},
|
},
|
||||||
|
"forward": {
|
||||||
|
"message": "Forward"
|
||||||
|
},
|
||||||
"done": {
|
"done": {
|
||||||
"message": "Done",
|
"message": "Done",
|
||||||
"description": "Label for done"
|
"description": "Label for done"
|
||||||
|
@ -2323,6 +2326,10 @@
|
||||||
"message": "New conversation",
|
"message": "New conversation",
|
||||||
"description": "Label for header when starting a new conversation"
|
"description": "Label for header when starting a new conversation"
|
||||||
},
|
},
|
||||||
|
"stories": {
|
||||||
|
"message": "Stories",
|
||||||
|
"description": "Label for header to go to stories view"
|
||||||
|
},
|
||||||
"contactSearchPlaceholder": {
|
"contactSearchPlaceholder": {
|
||||||
"message": "Search by name or phone number",
|
"message": "Search by name or phone number",
|
||||||
"description": "Placeholder to use when searching for contacts in the composer"
|
"description": "Placeholder to use when searching for contacts in the composer"
|
||||||
|
@ -6759,6 +6766,150 @@
|
||||||
"message": "Crop",
|
"message": "Crop",
|
||||||
"description": "Performs the 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": {
|
"WhatsNew__modal-title": {
|
||||||
"message": "What's New",
|
"message": "What's New",
|
||||||
"description": "Title for the whats new modal"
|
"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 {
|
message StoryMessage {
|
||||||
optional bytes profileKey = 1;
|
optional bytes profileKey = 1;
|
||||||
optional GroupContextV2 group = 2;
|
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 {
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
package signalservice;
|
package signalservice;
|
||||||
|
@ -78,6 +78,7 @@ message ContactRecord {
|
||||||
optional bool archived = 11;
|
optional bool archived = 11;
|
||||||
optional bool markedUnread = 12;
|
optional bool markedUnread = 12;
|
||||||
optional uint64 mutedUntilTimestamp = 13;
|
optional uint64 mutedUntilTimestamp = 13;
|
||||||
|
optional bool hideStory = 14;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GroupV1Record {
|
message GroupV1Record {
|
||||||
|
@ -97,6 +98,7 @@ message GroupV2Record {
|
||||||
optional bool markedUnread = 5;
|
optional bool markedUnread = 5;
|
||||||
optional uint64 mutedUntilTimestamp = 6;
|
optional uint64 mutedUntilTimestamp = 6;
|
||||||
optional bool dontNotifyForMentionsIfMuted = 7;
|
optional bool dontNotifyForMentionsIfMuted = 7;
|
||||||
|
optional bool hideStory = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AccountRecord {
|
message AccountRecord {
|
||||||
|
|
|
@ -2703,13 +2703,17 @@ button.ConversationDetails__action-button {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__icon-container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
&__compose-icon {
|
&__compose-icon {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2px;
|
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
|
// Module: Image
|
||||||
|
@ -7747,7 +7819,7 @@ button.module-image__border-overlay:focus {
|
||||||
z-index: $z-index-popup-overlay;
|
z-index: $z-index-popup-overlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-modal-host__container {
|
.module-modal-host__overlay-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
.module-Avatar {
|
.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/MessageBody.scss';
|
||||||
@import './components/MessageDetail.scss';
|
@import './components/MessageDetail.scss';
|
||||||
@import './components/Modal.scss';
|
@import './components/Modal.scss';
|
||||||
|
@import './components/MyStories.scss';
|
||||||
@import './components/PermissionsPopup.scss';
|
@import './components/PermissionsPopup.scss';
|
||||||
@import './components/Preferences.scss';
|
@import './components/Preferences.scss';
|
||||||
@import './components/ProfileEditor.scss';
|
@import './components/ProfileEditor.scss';
|
||||||
|
@ -97,6 +98,10 @@
|
||||||
@import './components/SearchResultsLoadingFakeRow.scss';
|
@import './components/SearchResultsLoadingFakeRow.scss';
|
||||||
@import './components/Select.scss';
|
@import './components/Select.scss';
|
||||||
@import './components/Slider.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/SystemMessage.scss';
|
||||||
@import './components/Tabs.scss';
|
@import './components/Tabs.scss';
|
||||||
@import './components/TimelineDateHeader.scss';
|
@import './components/TimelineDateHeader.scss';
|
||||||
|
|
|
@ -24,6 +24,7 @@ export type ConfigKeyType =
|
||||||
| 'desktop.sendSenderKey3'
|
| 'desktop.sendSenderKey3'
|
||||||
| 'desktop.showUserBadges.beta'
|
| 'desktop.showUserBadges.beta'
|
||||||
| 'desktop.showUserBadges2'
|
| 'desktop.showUserBadges2'
|
||||||
|
| 'desktop.stories'
|
||||||
| 'desktop.usernames'
|
| 'desktop.usernames'
|
||||||
| 'global.calling.maxGroupCallRingSize'
|
| 'global.calling.maxGroupCallRingSize'
|
||||||
| 'global.groupsv2.groupSizeHardLimit'
|
| 'global.groupsv2.groupSizeHardLimit'
|
||||||
|
|
|
@ -38,6 +38,7 @@ import { normalizeUuid } from './util/normalizeUuid';
|
||||||
import { filter } from './util/iterables';
|
import { filter } from './util/iterables';
|
||||||
import { isNotNil } from './util/isNotNil';
|
import { isNotNil } from './util/isNotNil';
|
||||||
import { IdleDetector } from './IdleDetector';
|
import { IdleDetector } from './IdleDetector';
|
||||||
|
import { loadStories, getStoriesForRedux } from './services/storyLoader';
|
||||||
import { senderCertificateService } from './services/senderCertificate';
|
import { senderCertificateService } from './services/senderCertificate';
|
||||||
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
|
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
|
||||||
import * as KeyboardLayout from './services/keyboardLayout';
|
import * as KeyboardLayout from './services/keyboardLayout';
|
||||||
|
@ -860,6 +861,7 @@ export async function startApp(): Promise<void> {
|
||||||
Stickers.load(),
|
Stickers.load(),
|
||||||
loadRecentEmojis(),
|
loadRecentEmojis(),
|
||||||
loadInitialBadgesState(),
|
loadInitialBadgesState(),
|
||||||
|
loadStories(),
|
||||||
window.textsecure.storage.protocol.hydrateCaches(),
|
window.textsecure.storage.protocol.hydrateCaches(),
|
||||||
]);
|
]);
|
||||||
await window.ConversationController.checkForConflicts();
|
await window.ConversationController.checkForConflicts();
|
||||||
|
@ -890,7 +892,10 @@ export async function startApp(): Promise<void> {
|
||||||
function initializeRedux() {
|
function initializeRedux() {
|
||||||
// Here we set up a full redux store with initial state for our LeftPane Root
|
// Here we set up a full redux store with initial state for our LeftPane Root
|
||||||
const convoCollection = window.getConversations();
|
const convoCollection = window.getConversations();
|
||||||
const initialState = getInitialState({ badges: initialBadgesState });
|
const initialState = getInitialState({
|
||||||
|
badges: initialBadgesState,
|
||||||
|
stories: getStoriesForRedux(),
|
||||||
|
});
|
||||||
|
|
||||||
const store = window.Signal.State.createStore(initialState);
|
const store = window.Signal.State.createStore(initialState);
|
||||||
window.reduxStore = store;
|
window.reduxStore = store;
|
||||||
|
@ -937,6 +942,7 @@ export async function startApp(): Promise<void> {
|
||||||
),
|
),
|
||||||
search: bindActionCreators(actionCreators.search, store.dispatch),
|
search: bindActionCreators(actionCreators.search, store.dispatch),
|
||||||
stickers: bindActionCreators(actionCreators.stickers, store.dispatch),
|
stickers: bindActionCreators(actionCreators.stickers, store.dispatch),
|
||||||
|
stories: bindActionCreators(actionCreators.stories, store.dispatch),
|
||||||
updates: bindActionCreators(actionCreators.updates, store.dispatch),
|
updates: bindActionCreators(actionCreators.updates, store.dispatch),
|
||||||
user: bindActionCreators(actionCreators.user, store.dispatch),
|
user: bindActionCreators(actionCreators.user, store.dispatch),
|
||||||
};
|
};
|
||||||
|
@ -2063,6 +2069,7 @@ export async function startApp(): Promise<void> {
|
||||||
'gv1-migration': true,
|
'gv1-migration': true,
|
||||||
senderKey: true,
|
senderKey: true,
|
||||||
changeNumber: true,
|
changeNumber: true,
|
||||||
|
stories: true,
|
||||||
}),
|
}),
|
||||||
updateOurUsername(),
|
updateOurUsername(),
|
||||||
]);
|
]);
|
||||||
|
@ -3268,7 +3275,7 @@ export async function startApp(): Promise<void> {
|
||||||
received_at_ms: data.receivedAtDate,
|
received_at_ms: data.receivedAtDate,
|
||||||
conversationId: descriptor.id,
|
conversationId: descriptor.id,
|
||||||
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
|
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
|
||||||
type: 'incoming',
|
type: data.message.isStory ? 'story' : 'incoming',
|
||||||
readStatus: ReadStatus.Unread,
|
readStatus: ReadStatus.Unread,
|
||||||
timestamp: data.timestamp,
|
timestamp: data.timestamp,
|
||||||
} as Partial<MessageAttributesType> as WhatIsThis);
|
} as Partial<MessageAttributesType> as WhatIsThis);
|
||||||
|
|
|
@ -16,15 +16,17 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
appView: AppViewType;
|
appView: AppViewType;
|
||||||
|
openInbox: () => void;
|
||||||
|
registerSingleDevice: (number: string, code: string) => Promise<void>;
|
||||||
renderCallManager: () => JSX.Element;
|
renderCallManager: () => JSX.Element;
|
||||||
renderGlobalModalContainer: () => JSX.Element;
|
renderGlobalModalContainer: () => JSX.Element;
|
||||||
openInbox: () => void;
|
isShowingStoriesView: boolean;
|
||||||
|
renderStories: () => JSX.Element;
|
||||||
requestVerification: (
|
requestVerification: (
|
||||||
type: 'sms' | 'voice',
|
type: 'sms' | 'voice',
|
||||||
number: string,
|
number: string,
|
||||||
token: string
|
token: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
registerSingleDevice: (number: string, code: string) => Promise<void>;
|
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
} & ComponentProps<typeof Inbox>;
|
} & ComponentProps<typeof Inbox>;
|
||||||
|
|
||||||
|
@ -36,11 +38,13 @@ export const App = ({
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
i18n,
|
i18n,
|
||||||
isCustomizingPreferredReactions,
|
isCustomizingPreferredReactions,
|
||||||
|
isShowingStoriesView,
|
||||||
renderCallManager,
|
renderCallManager,
|
||||||
renderCustomizingPreferredReactionsModal,
|
renderCustomizingPreferredReactionsModal,
|
||||||
renderGlobalModalContainer,
|
renderGlobalModalContainer,
|
||||||
renderSafetyNumber,
|
renderSafetyNumber,
|
||||||
openInbox,
|
openInbox,
|
||||||
|
renderStories,
|
||||||
requestVerification,
|
requestVerification,
|
||||||
registerSingleDevice,
|
registerSingleDevice,
|
||||||
theme,
|
theme,
|
||||||
|
@ -118,6 +122,7 @@ export const App = ({
|
||||||
>
|
>
|
||||||
{renderGlobalModalContainer()}
|
{renderGlobalModalContainer()}
|
||||||
{renderCallManager()}
|
{renderCallManager()}
|
||||||
|
{isShowingStoriesView && renderStories()}
|
||||||
{contents}
|
{contents}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2020-2021 Signal Messenger, LLC
|
// Copyright 2020-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
@ -9,7 +9,7 @@ import { boolean, select, text } from '@storybook/addon-knobs';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
import type { Props } from './Avatar';
|
import type { Props } from './Avatar';
|
||||||
import { Avatar, AvatarBlur } from './Avatar';
|
import { Avatar, AvatarBlur, AvatarStoryRing } from './Avatar';
|
||||||
import { setupI18n } from '../util/setupI18n';
|
import { setupI18n } from '../util/setupI18n';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
import type { AvatarColorType } from '../types/Colors';
|
import type { AvatarColorType } from '../types/Colors';
|
||||||
|
@ -236,3 +236,23 @@ story.add('Blurred with "click to view"', () => {
|
||||||
|
|
||||||
return <Avatar {...props} size={112} />;
|
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,
|
ONE_HUNDRED_TWELVE = 112,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AvatarStoryRing {
|
||||||
|
Unread = 'Unread',
|
||||||
|
Read = 'Read',
|
||||||
|
}
|
||||||
|
|
||||||
type BadgePlacementType = { bottom: number; right: number };
|
type BadgePlacementType = { bottom: number; right: number };
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
|
@ -65,6 +70,7 @@ export type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
unblurredAvatarPath?: string;
|
unblurredAvatarPath?: string;
|
||||||
searchResult?: boolean;
|
searchResult?: boolean;
|
||||||
|
storyRing?: AvatarStoryRing;
|
||||||
|
|
||||||
onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
|
onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
|
||||||
onClickBadge?: (event: MouseEvent<HTMLButtonElement>) => unknown;
|
onClickBadge?: (event: MouseEvent<HTMLButtonElement>) => unknown;
|
||||||
|
@ -118,6 +124,7 @@ export const Avatar: FunctionComponent<Props> = ({
|
||||||
title,
|
title,
|
||||||
unblurredAvatarPath,
|
unblurredAvatarPath,
|
||||||
searchResult,
|
searchResult,
|
||||||
|
storyRing,
|
||||||
blur = getDefaultBlur({
|
blur = getDefaultBlur({
|
||||||
acceptedMessageRequest,
|
acceptedMessageRequest,
|
||||||
avatarPath,
|
avatarPath,
|
||||||
|
@ -301,6 +308,9 @@ export const Avatar: FunctionComponent<Props> = ({
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-Avatar',
|
'module-Avatar',
|
||||||
hasImage ? 'module-Avatar--with-image' : 'module-Avatar--no-image',
|
hasImage ? 'module-Avatar--with-image' : 'module-Avatar--no-image',
|
||||||
|
storyRing && 'module-Avatar--with-story',
|
||||||
|
storyRing === AvatarStoryRing.Unread &&
|
||||||
|
'module-Avatar--with-story--unread',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2019-2021 Signal Messenger, LLC
|
// Copyright 2019-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { MutableRefObject } from 'react';
|
import type { MutableRefObject } from 'react';
|
||||||
|
@ -623,7 +623,7 @@ export const CompositionArea = ({
|
||||||
// This one is for redux...
|
// This one is for redux...
|
||||||
setQuotedMessage(undefined);
|
setQuotedMessage(undefined);
|
||||||
// and this is for conversation_view.
|
// and this is for conversation_view.
|
||||||
clearQuotedMessage();
|
clearQuotedMessage?.();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2019-2021 Signal Messenger, LLC
|
// Copyright 2019-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
@ -61,6 +61,7 @@ export type InputApi = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
|
children?: React.ReactNode;
|
||||||
readonly i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
readonly getPreferredBadge: PreferredBadgeSelectorType;
|
readonly getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
|
@ -71,6 +72,7 @@ export type Props = {
|
||||||
readonly draftBodyRanges?: Array<BodyRangeType>;
|
readonly draftBodyRanges?: Array<BodyRangeType>;
|
||||||
readonly moduleClassName?: string;
|
readonly moduleClassName?: string;
|
||||||
readonly theme: ThemeType;
|
readonly theme: ThemeType;
|
||||||
|
readonly placeholder?: string;
|
||||||
sortedGroupMembers?: Array<ConversationType>;
|
sortedGroupMembers?: Array<ConversationType>;
|
||||||
onDirtyChange?(dirty: boolean): unknown;
|
onDirtyChange?(dirty: boolean): unknown;
|
||||||
onEditorStateChange?(
|
onEditorStateChange?(
|
||||||
|
@ -85,8 +87,8 @@ export type Props = {
|
||||||
mentions: Array<BodyRangeType>,
|
mentions: Array<BodyRangeType>,
|
||||||
timestamp: number
|
timestamp: number
|
||||||
): unknown;
|
): unknown;
|
||||||
getQuotedMessage(): unknown;
|
getQuotedMessage?(): unknown;
|
||||||
clearQuotedMessage(): unknown;
|
clearQuotedMessage?(): unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_LENGTH = 64 * 1024;
|
const MAX_LENGTH = 64 * 1024;
|
||||||
|
@ -94,6 +96,7 @@ const BASE_CLASS_NAME = 'module-composition-input';
|
||||||
|
|
||||||
export function CompositionInput(props: Props): React.ReactElement {
|
export function CompositionInput(props: Props): React.ReactElement {
|
||||||
const {
|
const {
|
||||||
|
children,
|
||||||
i18n,
|
i18n,
|
||||||
disabled,
|
disabled,
|
||||||
large,
|
large,
|
||||||
|
@ -101,6 +104,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||||
moduleClassName,
|
moduleClassName,
|
||||||
onPickEmoji,
|
onPickEmoji,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
placeholder,
|
||||||
skinTone,
|
skinTone,
|
||||||
draftText,
|
draftText,
|
||||||
draftBodyRanges,
|
draftBodyRanges,
|
||||||
|
@ -341,8 +345,8 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getQuotedMessage()) {
|
if (getQuotedMessage?.()) {
|
||||||
clearQuotedMessage();
|
clearQuotedMessage?.();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -561,7 +565,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
formats={['emoji', 'mention']}
|
formats={['emoji', 'mention']}
|
||||||
placeholder={i18n('sendMessage')}
|
placeholder={placeholder || i18n('sendMessage')}
|
||||||
readOnly={disabled}
|
readOnly={disabled}
|
||||||
ref={element => {
|
ref={element => {
|
||||||
if (element) {
|
if (element) {
|
||||||
|
@ -635,9 +639,11 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||||
onClick={focus}
|
onClick={focus}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
getClassName('__input__scroller'),
|
getClassName('__input__scroller'),
|
||||||
large ? getClassName('__input__scroller--large') : null
|
large ? getClassName('__input__scroller--large') : null,
|
||||||
|
children ? getClassName('__input--with-children') : null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{children}
|
||||||
{reactQuill}
|
{reactQuill}
|
||||||
{emojiCompletionElement}
|
{emojiCompletionElement}
|
||||||
{mentionCompletionElement}
|
{mentionCompletionElement}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
@ -19,21 +19,20 @@ const getDefaultProps = (): PropsType<number> => ({
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
{
|
{
|
||||||
label: '1',
|
label: '1',
|
||||||
value: 1,
|
onClick: action('1'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '2',
|
label: '2',
|
||||||
value: 2,
|
onClick: action('2'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '3',
|
label: '3',
|
||||||
value: 3,
|
onClick: action('3'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onChange: action('onChange'),
|
|
||||||
value: 1,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO DESKTOP-3184
|
||||||
story.add('Default', () => {
|
story.add('Default', () => {
|
||||||
return <ContextMenu {...getDefaultProps()} />;
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { KeyboardEvent } from 'react';
|
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 classNames from 'classnames';
|
||||||
import { usePopper } from 'react-popper';
|
import { usePopper } from 'react-popper';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
@ -12,27 +14,128 @@ import type { LocalizerType } from '../types/Util';
|
||||||
import { themeClassName } from '../util/theme';
|
import { themeClassName } from '../util/theme';
|
||||||
|
|
||||||
type OptionType<T> = {
|
type OptionType<T> = {
|
||||||
|
readonly description?: string;
|
||||||
readonly icon?: string;
|
readonly icon?: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly description?: string;
|
readonly onClick: (value?: T) => unknown;
|
||||||
readonly value: T;
|
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> = {
|
export type PropsType<T> = {
|
||||||
readonly buttonClassName?: string;
|
readonly buttonClassName?: string;
|
||||||
readonly i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
readonly menuOptions: ReadonlyArray<OptionType<T>>;
|
} & Pick<
|
||||||
readonly onChange: (value: T) => unknown;
|
ContextMenuPropsType<T>,
|
||||||
readonly theme?: Theme;
|
'menuOptions' | 'popperOptions' | 'theme' | 'title' | 'value'
|
||||||
readonly title?: string;
|
>;
|
||||||
readonly value: T;
|
|
||||||
};
|
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>({
|
export function ContextMenu<T>({
|
||||||
buttonClassName,
|
buttonClassName,
|
||||||
i18n,
|
i18n,
|
||||||
menuOptions,
|
menuOptions,
|
||||||
onChange,
|
popperOptions,
|
||||||
theme,
|
theme,
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
|
@ -42,13 +145,6 @@ export function ContextMenu<T>({
|
||||||
undefined
|
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) => {
|
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||||
if (!menuShowing) {
|
if (!menuShowing) {
|
||||||
if (ev.key === 'Enter') {
|
if (ev.key === 'Enter') {
|
||||||
|
@ -77,7 +173,8 @@ export function ContextMenu<T>({
|
||||||
|
|
||||||
if (ev.key === 'Enter') {
|
if (ev.key === 'Enter') {
|
||||||
if (focusedIndex !== undefined) {
|
if (focusedIndex !== undefined) {
|
||||||
onChange(menuOptions[focusedIndex].value);
|
const focusedOption = menuOptions[focusedIndex];
|
||||||
|
focusedOption.onClick(focusedOption.value);
|
||||||
}
|
}
|
||||||
setMenuShowing(false);
|
setMenuShowing(false);
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
@ -85,39 +182,15 @@ export function ContextMenu<T>({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
// We use regular MouseEvent below, and this one uses React.MouseEvent
|
||||||
setMenuShowing(false);
|
const handleClick = (ev: KeyboardEvent | React.MouseEvent) => {
|
||||||
setFocusedIndex(undefined);
|
setMenuShowing(true);
|
||||||
}, [setMenuShowing]);
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
const [referenceElement, setReferenceElement] =
|
const [referenceElement, setReferenceElement] =
|
||||||
useState<HTMLButtonElement | null>(null);
|
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 (
|
return (
|
||||||
<div className={theme ? themeClassName(theme) : undefined}>
|
<div className={theme ? themeClassName(theme) : undefined}>
|
||||||
|
@ -132,55 +205,22 @@ export function ContextMenu<T>({
|
||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
{menuShowing && (
|
<FocusTrap
|
||||||
<div
|
focusTrapOptions={{
|
||||||
className="ContextMenu__popper"
|
allowOutsideClick: true,
|
||||||
ref={setPopperElement}
|
}}
|
||||||
style={styles.popper}
|
>
|
||||||
{...attributes.popper}
|
<ContextMenuPopper
|
||||||
>
|
focusedIndex={focusedIndex}
|
||||||
{title && <div className="ContextMenu__title">{title}</div>}
|
isMenuShowing={menuShowing}
|
||||||
{menuOptions.map((option, index) => (
|
menuOptions={menuOptions}
|
||||||
<button
|
onClose={() => setMenuShowing(false)}
|
||||||
aria-label={option.label}
|
popperOptions={popperOptions}
|
||||||
className={classNames({
|
referenceElement={referenceElement}
|
||||||
ContextMenu__option: true,
|
title={title}
|
||||||
'ContextMenu__option--focused': focusedIndex === index,
|
value={value}
|
||||||
})}
|
/>
|
||||||
key={option.label}
|
</FocusTrap>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,10 +28,15 @@ import { ScrollBehavior } from '../types/Util';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import { usePrevious } from '../hooks/usePrevious';
|
import { usePrevious } from '../hooks/usePrevious';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { strictAssert } from '../util/assert';
|
|
||||||
import { isSorted } from '../util/isSorted';
|
|
||||||
import type { WidthBreakpoint } from './_util';
|
import type { WidthBreakpoint } from './_util';
|
||||||
import { getConversationListWidthBreakpoint } 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 { ConversationList } from './ConversationList';
|
||||||
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
||||||
|
@ -42,15 +47,6 @@ import type {
|
||||||
SaveAvatarToDiskActionType,
|
SaveAvatarToDiskActionType,
|
||||||
} from '../types/Avatar';
|
} 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 {
|
export enum LeftPaneMode {
|
||||||
Inbox,
|
Inbox,
|
||||||
Search,
|
Search,
|
||||||
|
@ -499,13 +495,6 @@ export const LeftPane: React.FC<PropsType> = ({
|
||||||
selectedConversationId
|
selectedConversationId
|
||||||
);
|
);
|
||||||
|
|
||||||
let width: number;
|
|
||||||
if (requiresFullWidth || preferredWidth >= SNAP_WIDTH) {
|
|
||||||
width = Math.max(preferredWidth, MIN_FULL_WIDTH);
|
|
||||||
} else {
|
|
||||||
width = MIN_WIDTH;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isScrollable = helper.isScrollable();
|
const isScrollable = helper.isScrollable();
|
||||||
|
|
||||||
let rowIndexToScrollTo: undefined | number;
|
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.
|
// It also ensures that we scroll to the top when switching views.
|
||||||
const listKey = preRowsNode ? 1 : 0;
|
const listKey = preRowsNode ? 1 : 0;
|
||||||
|
|
||||||
|
const width = getWidthFromPreferredWidth(preferredWidth, {
|
||||||
|
requiresFullWidth,
|
||||||
|
});
|
||||||
|
|
||||||
const widthBreakpoint = getConversationListWidthBreakpoint(width);
|
const widthBreakpoint = getConversationListWidthBreakpoint(width);
|
||||||
|
|
||||||
// We disable this lint rule because we're trying to capture bubbled events. See [the
|
// 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;
|
text(name, value || '') || undefined;
|
||||||
|
|
||||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
|
areStoriesEnabled: false,
|
||||||
theme: ThemeType.light,
|
theme: ThemeType.light,
|
||||||
|
|
||||||
phoneNumber: optionalText('phoneNumber', overrideProps.phoneNumber),
|
phoneNumber: optionalText('phoneNumber', overrideProps.phoneNumber),
|
||||||
|
@ -37,6 +38,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
showArchivedConversations: action('showArchivedConversations'),
|
showArchivedConversations: action('showArchivedConversations'),
|
||||||
startComposing: action('startComposing'),
|
startComposing: action('startComposing'),
|
||||||
toggleProfileEditor: action('toggleProfileEditor'),
|
toggleProfileEditor: action('toggleProfileEditor'),
|
||||||
|
toggleStoriesView: action('toggleStoriesView'),
|
||||||
});
|
});
|
||||||
|
|
||||||
story.add('Basic', () => {
|
story.add('Basic', () => {
|
||||||
|
@ -68,3 +70,7 @@ story.add('Update Available', () => {
|
||||||
|
|
||||||
return <MainHeader {...props} />;
|
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';
|
import type { BadgeType } from '../badges/types';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
|
areStoriesEnabled: boolean;
|
||||||
avatarPath?: string;
|
avatarPath?: string;
|
||||||
badge?: BadgeType;
|
badge?: BadgeType;
|
||||||
color?: AvatarColorType;
|
color?: AvatarColorType;
|
||||||
|
@ -30,6 +31,7 @@ export type PropsType = {
|
||||||
startComposing: () => void;
|
startComposing: () => void;
|
||||||
startUpdate: () => unknown;
|
startUpdate: () => unknown;
|
||||||
toggleProfileEditor: () => void;
|
toggleProfileEditor: () => void;
|
||||||
|
toggleStoriesView: () => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateType = {
|
type StateType = {
|
||||||
|
@ -111,6 +113,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
||||||
|
|
||||||
public override render(): JSX.Element {
|
public override render(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
|
areStoriesEnabled,
|
||||||
avatarPath,
|
avatarPath,
|
||||||
badge,
|
badge,
|
||||||
color,
|
color,
|
||||||
|
@ -125,6 +128,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
||||||
theme,
|
theme,
|
||||||
title,
|
title,
|
||||||
toggleProfileEditor,
|
toggleProfileEditor,
|
||||||
|
toggleStoriesView,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { showingAvatarPopup, popperRoot } = this.state;
|
const { showingAvatarPopup, popperRoot } = this.state;
|
||||||
|
|
||||||
|
@ -204,13 +208,24 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
</Manager>
|
</Manager>
|
||||||
<button
|
<div className="module-main-header__icon-container">
|
||||||
aria-label={i18n('newConversation')}
|
{areStoriesEnabled && (
|
||||||
className="module-main-header__compose-icon"
|
<button
|
||||||
onClick={startComposing}
|
aria-label={i18n('stories')}
|
||||||
title={i18n('newConversation')}
|
className="module-main-header__stories-icon"
|
||||||
type="button"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import Measure from 'react-measure';
|
import Measure from 'react-measure';
|
||||||
|
@ -582,20 +582,22 @@ export const MediaEditor = ({
|
||||||
{
|
{
|
||||||
icon: 'MediaEditor__icon--text-regular',
|
icon: 'MediaEditor__icon--text-regular',
|
||||||
label: i18n('MediaEditor__text--regular'),
|
label: i18n('MediaEditor__text--regular'),
|
||||||
|
onClick: () => setTextStyle(TextStyle.Regular),
|
||||||
value: TextStyle.Regular,
|
value: TextStyle.Regular,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'MediaEditor__icon--text-highlight',
|
icon: 'MediaEditor__icon--text-highlight',
|
||||||
label: i18n('MediaEditor__text--highlight'),
|
label: i18n('MediaEditor__text--highlight'),
|
||||||
|
onClick: () => setTextStyle(TextStyle.Highlight),
|
||||||
value: TextStyle.Highlight,
|
value: TextStyle.Highlight,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'MediaEditor__icon--text-outline',
|
icon: 'MediaEditor__icon--text-outline',
|
||||||
label: i18n('MediaEditor__text--outline'),
|
label: i18n('MediaEditor__text--outline'),
|
||||||
|
onClick: () => setTextStyle(TextStyle.Outline),
|
||||||
value: TextStyle.Outline,
|
value: TextStyle.Outline,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={value => setTextStyle(value)}
|
|
||||||
theme={Theme.Dark}
|
theme={Theme.Dark}
|
||||||
value={textStyle}
|
value={textStyle}
|
||||||
/>
|
/>
|
||||||
|
@ -636,15 +638,16 @@ export const MediaEditor = ({
|
||||||
{
|
{
|
||||||
icon: 'MediaEditor__icon--draw-pen',
|
icon: 'MediaEditor__icon--draw-pen',
|
||||||
label: i18n('MediaEditor__draw--pen'),
|
label: i18n('MediaEditor__draw--pen'),
|
||||||
|
onClick: () => setDrawTool(DrawTool.Pen),
|
||||||
value: DrawTool.Pen,
|
value: DrawTool.Pen,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'MediaEditor__icon--draw-highlighter',
|
icon: 'MediaEditor__icon--draw-highlighter',
|
||||||
label: i18n('MediaEditor__draw--highlighter'),
|
label: i18n('MediaEditor__draw--highlighter'),
|
||||||
|
onClick: () => setDrawTool(DrawTool.Highlighter),
|
||||||
value: DrawTool.Highlighter,
|
value: DrawTool.Highlighter,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={value => setDrawTool(value)}
|
|
||||||
theme={Theme.Dark}
|
theme={Theme.Dark}
|
||||||
value={drawTool}
|
value={drawTool}
|
||||||
/>
|
/>
|
||||||
|
@ -664,25 +667,28 @@ export const MediaEditor = ({
|
||||||
{
|
{
|
||||||
icon: 'MediaEditor__icon--width-thin',
|
icon: 'MediaEditor__icon--width-thin',
|
||||||
label: i18n('MediaEditor__draw--thin'),
|
label: i18n('MediaEditor__draw--thin'),
|
||||||
|
onClick: () => setDrawWidth(DrawWidth.Thin),
|
||||||
value: DrawWidth.Thin,
|
value: DrawWidth.Thin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'MediaEditor__icon--width-regular',
|
icon: 'MediaEditor__icon--width-regular',
|
||||||
label: i18n('MediaEditor__draw--regular'),
|
label: i18n('MediaEditor__draw--regular'),
|
||||||
|
onClick: () => setDrawWidth(DrawWidth.Regular),
|
||||||
value: DrawWidth.Regular,
|
value: DrawWidth.Regular,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'MediaEditor__icon--width-medium',
|
icon: 'MediaEditor__icon--width-medium',
|
||||||
label: i18n('MediaEditor__draw--medium'),
|
label: i18n('MediaEditor__draw--medium'),
|
||||||
|
onClick: () => setDrawWidth(DrawWidth.Medium),
|
||||||
value: DrawWidth.Medium,
|
value: DrawWidth.Medium,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'MediaEditor__icon--width-heavy',
|
icon: 'MediaEditor__icon--width-heavy',
|
||||||
label: i18n('MediaEditor__draw--heavy'),
|
label: i18n('MediaEditor__draw--heavy'),
|
||||||
|
onClick: () => setDrawWidth(DrawWidth.Heavy),
|
||||||
value: DrawWidth.Heavy,
|
value: DrawWidth.Heavy,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={value => setDrawWidth(value)}
|
|
||||||
theme={Theme.Dark}
|
theme={Theme.Dark}
|
||||||
value={drawWidth}
|
value={drawWidth}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ReactElement, ReactNode } from 'react';
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
@ -25,6 +25,7 @@ type PropsType = {
|
||||||
moduleClassName?: string;
|
moduleClassName?: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
|
useFocusTrap?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModalPropsType = PropsType & {
|
type ModalPropsType = PropsType & {
|
||||||
|
@ -44,6 +45,7 @@ export function Modal({
|
||||||
onClose = noop,
|
onClose = noop,
|
||||||
title,
|
title,
|
||||||
theme,
|
theme,
|
||||||
|
useFocusTrap,
|
||||||
}: Readonly<ModalPropsType>): ReactElement {
|
}: Readonly<ModalPropsType>): ReactElement {
|
||||||
const { close, modalStyles, overlayStyles } = useAnimated(onClose, {
|
const { close, modalStyles, overlayStyles } = useAnimated(onClose, {
|
||||||
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
|
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
|
||||||
|
@ -55,10 +57,12 @@ export function Modal({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalHost
|
<ModalHost
|
||||||
|
moduleClassName={moduleClassName}
|
||||||
noMouseClose={noMouseClose}
|
noMouseClose={noMouseClose}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
overlayStyles={overlayStyles}
|
overlayStyles={overlayStyles}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
useFocusTrap={useFocusTrap}
|
||||||
>
|
>
|
||||||
<animated.div style={modalStyles}>
|
<animated.div style={modalStyles}>
|
||||||
<ModalWindow
|
<ModalWindow
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2019-2020 Signal Messenger, LLC
|
// Copyright 2019-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
@ -10,28 +10,33 @@ import classNames from 'classnames';
|
||||||
|
|
||||||
import type { ModalConfigType } from '../hooks/useAnimated';
|
import type { ModalConfigType } from '../hooks/useAnimated';
|
||||||
import type { Theme } from '../util/theme';
|
import type { Theme } from '../util/theme';
|
||||||
|
import { getClassNamesFor } from '../util/getClassNamesFor';
|
||||||
import { themeClassName } from '../util/theme';
|
import { themeClassName } from '../util/theme';
|
||||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||||
|
|
||||||
export type PropsType = Readonly<{
|
export type PropsType = Readonly<{
|
||||||
children: React.ReactElement;
|
children: React.ReactElement;
|
||||||
|
moduleClassName?: string;
|
||||||
noMouseClose?: boolean;
|
noMouseClose?: boolean;
|
||||||
onClose: () => unknown;
|
onClose: () => unknown;
|
||||||
onEscape?: () => unknown;
|
onEscape?: () => unknown;
|
||||||
|
onTopOfEverything?: boolean;
|
||||||
overlayStyles?: SpringValues<ModalConfigType>;
|
overlayStyles?: SpringValues<ModalConfigType>;
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
onTopOfEverything?: boolean;
|
useFocusTrap?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const ModalHost = React.memo(
|
export const ModalHost = React.memo(
|
||||||
({
|
({
|
||||||
children,
|
children,
|
||||||
|
moduleClassName,
|
||||||
noMouseClose,
|
noMouseClose,
|
||||||
onClose,
|
onClose,
|
||||||
onEscape,
|
onEscape,
|
||||||
theme,
|
|
||||||
overlayStyles,
|
|
||||||
onTopOfEverything,
|
onTopOfEverything,
|
||||||
|
overlayStyles,
|
||||||
|
theme,
|
||||||
|
useFocusTrap = true,
|
||||||
}: PropsType) => {
|
}: PropsType) => {
|
||||||
const [root, setRoot] = React.useState<HTMLElement | null>(null);
|
const [root, setRoot] = React.useState<HTMLElement | null>(null);
|
||||||
const [isMouseDown, setIsMouseDown] = React.useState(false);
|
const [isMouseDown, setIsMouseDown] = React.useState(false);
|
||||||
|
@ -74,26 +79,35 @@ export const ModalHost = React.memo(
|
||||||
theme ? themeClassName(theme) : undefined,
|
theme ? themeClassName(theme) : undefined,
|
||||||
onTopOfEverything ? 'module-modal-host--on-top-of-everything' : 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
|
return root
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<FocusTrap
|
useFocusTrap ? (
|
||||||
focusTrapOptions={{
|
<FocusTrap
|
||||||
// This is alright because the overlay covers the entire screen
|
focusTrapOptions={{
|
||||||
allowOutsideClick: false,
|
// This is alright because the overlay covers the entire screen
|
||||||
}}
|
allowOutsideClick: false,
|
||||||
>
|
}}
|
||||||
<div className={className}>
|
>
|
||||||
<animated.div
|
{modalContent}
|
||||||
role="presentation"
|
</FocusTrap>
|
||||||
className="module-modal-host__overlay"
|
) : (
|
||||||
onMouseDown={noMouseClose ? undefined : handleMouseDown}
|
modalContent
|
||||||
onMouseUp={noMouseClose ? undefined : handleMouseUp}
|
),
|
||||||
style={overlayStyles}
|
|
||||||
/>
|
|
||||||
<div className="module-modal-host__container">{children}</div>
|
|
||||||
</div>
|
|
||||||
</FocusTrap>,
|
|
||||||
root
|
root
|
||||||
)
|
)
|
||||||
: null;
|
: 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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { KeyboardEvent, ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { assert } from '../util/assert';
|
|
||||||
import { getClassNamesFor } from '../util/getClassNamesFor';
|
|
||||||
|
|
||||||
type Tab = {
|
import type { TabsOptionsType } from '../hooks/useTabs';
|
||||||
id: string;
|
import { useTabs } from '../hooks/useTabs';
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
children: (renderProps: { selectedTab: string }) => ReactNode;
|
children: (renderProps: { selectedTab: string }) => ReactNode;
|
||||||
initialSelectedTab?: string;
|
} & TabsOptionsType;
|
||||||
moduleClassName?: string;
|
|
||||||
onTabChange?: (selectedTab: string) => unknown;
|
|
||||||
tabs: Array<Tab>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Tabs = ({
|
export const Tabs = ({
|
||||||
children,
|
children,
|
||||||
|
@ -27,42 +18,16 @@ export const Tabs = ({
|
||||||
onTabChange,
|
onTabChange,
|
||||||
tabs,
|
tabs,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
assert(tabs.length, 'Tabs needs more than 1 tab present');
|
const { selectedTab, tabsHeaderElement } = useTabs({
|
||||||
|
initialSelectedTab,
|
||||||
const [selectedTab, setSelectedTab] = useState<string>(
|
moduleClassName,
|
||||||
initialSelectedTab || tabs[0].id
|
onTabChange,
|
||||||
);
|
tabs,
|
||||||
|
});
|
||||||
const getClassName = getClassNamesFor('Tabs', moduleClassName);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={getClassName('')}>
|
{tabsHeaderElement}
|
||||||
{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>
|
|
||||||
{children({ selectedTab })}
|
{children({ selectedTab })}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -213,6 +213,9 @@ story.add('Image Only', () => {
|
||||||
isVoiceMessage: false,
|
isVoiceMessage: false,
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
contentType: IMAGE_PNG,
|
contentType: IMAGE_PNG,
|
||||||
|
height: 100,
|
||||||
|
width: 100,
|
||||||
|
path: pngUrl,
|
||||||
objectUrl: pngUrl,
|
objectUrl: pngUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -228,6 +231,9 @@ story.add('Image Attachment', () => {
|
||||||
isVoiceMessage: false,
|
isVoiceMessage: false,
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
contentType: IMAGE_PNG,
|
contentType: IMAGE_PNG,
|
||||||
|
height: 100,
|
||||||
|
width: 100,
|
||||||
|
path: pngUrl,
|
||||||
objectUrl: pngUrl,
|
objectUrl: pngUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -270,6 +276,9 @@ story.add('Video Only', () => {
|
||||||
isVoiceMessage: false,
|
isVoiceMessage: false,
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
contentType: IMAGE_PNG,
|
contentType: IMAGE_PNG,
|
||||||
|
height: 100,
|
||||||
|
width: 100,
|
||||||
|
path: pngUrl,
|
||||||
objectUrl: pngUrl,
|
objectUrl: pngUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -288,6 +297,9 @@ story.add('Video Attachment', () => {
|
||||||
isVoiceMessage: false,
|
isVoiceMessage: false,
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
contentType: IMAGE_PNG,
|
contentType: IMAGE_PNG,
|
||||||
|
height: 100,
|
||||||
|
width: 100,
|
||||||
|
path: pngUrl,
|
||||||
objectUrl: 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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
@ -10,6 +10,7 @@ import * as MIME from '../../types/MIME';
|
||||||
import * as GoogleChrome from '../../util/GoogleChrome';
|
import * as GoogleChrome from '../../util/GoogleChrome';
|
||||||
|
|
||||||
import { MessageBody } from './MessageBody';
|
import { MessageBody } from './MessageBody';
|
||||||
|
import type { AttachmentType, ThumbnailType } from '../../types/Attachment';
|
||||||
import type { BodyRangesType, LocalizerType } from '../../types/Util';
|
import type { BodyRangesType, LocalizerType } from '../../types/Util';
|
||||||
import type {
|
import type {
|
||||||
ConversationColorType,
|
ConversationColorType,
|
||||||
|
@ -40,19 +41,10 @@ type State = {
|
||||||
imageBroken: boolean;
|
imageBroken: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuotedAttachmentType = {
|
export type QuotedAttachmentType = Pick<
|
||||||
contentType: MIME.MIMEType;
|
AttachmentType,
|
||||||
fileName?: string;
|
'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail'
|
||||||
/** Not included in protobuf */
|
>;
|
||||||
isVoiceMessage: boolean;
|
|
||||||
thumbnail?: Attachment;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Attachment = {
|
|
||||||
contentType: MIME.MIMEType;
|
|
||||||
/** Not included in protobuf, and is loaded asynchronously */
|
|
||||||
objectUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function validateQuote(quote: Props): boolean {
|
function validateQuote(quote: Props): boolean {
|
||||||
if (quote.text) {
|
if (quote.text) {
|
||||||
|
@ -75,12 +67,12 @@ function getAttachment(
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getObjectUrl(thumbnail: Attachment | undefined): string | undefined {
|
function getUrl(thumbnail?: ThumbnailType): string | undefined {
|
||||||
if (thumbnail && thumbnail.objectUrl) {
|
if (!thumbnail) {
|
||||||
return thumbnail.objectUrl;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return thumbnail.objectUrl || thumbnail.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTypeLabel({
|
function getTypeLabel({
|
||||||
|
@ -92,7 +84,7 @@ function getTypeLabel({
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
isViewOnce?: boolean;
|
isViewOnce?: boolean;
|
||||||
contentType: MIME.MIMEType;
|
contentType: MIME.MIMEType;
|
||||||
isVoiceMessage: boolean;
|
isVoiceMessage?: boolean;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||||
if (isViewOnce) {
|
if (isViewOnce) {
|
||||||
|
@ -249,20 +241,20 @@ export class Quote extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { contentType, thumbnail } = attachment;
|
const { contentType, thumbnail } = attachment;
|
||||||
const objectUrl = getObjectUrl(thumbnail);
|
const url = getUrl(thumbnail);
|
||||||
|
|
||||||
if (isViewOnce) {
|
if (isViewOnce) {
|
||||||
return this.renderIcon('view-once');
|
return this.renderIcon('view-once');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||||
return objectUrl && !imageBroken
|
return url && !imageBroken
|
||||||
? this.renderImage(objectUrl, 'play')
|
? this.renderImage(url, 'play')
|
||||||
: this.renderIcon('movie');
|
: this.renderIcon('movie');
|
||||||
}
|
}
|
||||||
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
||||||
return objectUrl && !imageBroken
|
return url && !imageBroken
|
||||||
? this.renderImage(objectUrl)
|
? this.renderImage(url)
|
||||||
: this.renderIcon('image');
|
: this.renderIcon('image');
|
||||||
}
|
}
|
||||||
if (MIME.isAudio(contentType)) {
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
@ -12,6 +12,7 @@ import { EmojiPicker } from './EmojiPicker';
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
|
|
||||||
export type OwnProps = {
|
export type OwnProps = {
|
||||||
|
readonly className?: string;
|
||||||
readonly closeOnPick?: boolean;
|
readonly closeOnPick?: boolean;
|
||||||
readonly emoji?: string;
|
readonly emoji?: string;
|
||||||
readonly i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
|
@ -26,6 +27,7 @@ export type Props = OwnProps &
|
||||||
|
|
||||||
export const EmojiButton = React.memo(
|
export const EmojiButton = React.memo(
|
||||||
({
|
({
|
||||||
|
className,
|
||||||
closeOnPick,
|
closeOnPick,
|
||||||
emoji,
|
emoji,
|
||||||
i18n,
|
i18n,
|
||||||
|
@ -117,7 +119,7 @@ export const EmojiButton = React.memo(
|
||||||
type="button"
|
type="button"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={handleClickButton}
|
onClick={handleClickButton}
|
||||||
className={classNames({
|
className={classNames(className, {
|
||||||
'module-emoji-button__button': true,
|
'module-emoji-button__button': true,
|
||||||
'module-emoji-button__button--active': open,
|
'module-emoji-button__button--active': open,
|
||||||
'module-emoji-button__button--has-emoji': Boolean(emoji),
|
'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,
|
preview,
|
||||||
quote,
|
quote,
|
||||||
sticker,
|
sticker,
|
||||||
|
storyContextTimestamp,
|
||||||
} = await getMessageSendData({ log, message });
|
} = await getMessageSendData({ log, message });
|
||||||
|
|
||||||
let messageSendPromise: Promise<CallbackResultType | void>;
|
let messageSendPromise: Promise<CallbackResultType | void>;
|
||||||
|
@ -253,6 +254,7 @@ export async function sendNormalMessage(
|
||||||
groupId: undefined,
|
groupId: undefined,
|
||||||
profileKey,
|
profileKey,
|
||||||
options: sendOptions,
|
options: sendOptions,
|
||||||
|
storyContextTimestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -400,6 +402,7 @@ async function getMessageSendData({
|
||||||
preview: Array<LinkPreviewType>;
|
preview: Array<LinkPreviewType>;
|
||||||
quote: WhatIsThis;
|
quote: WhatIsThis;
|
||||||
sticker: WhatIsThis;
|
sticker: WhatIsThis;
|
||||||
|
storyContextTimestamp?: number;
|
||||||
}> {
|
}> {
|
||||||
const {
|
const {
|
||||||
loadAttachmentData,
|
loadAttachmentData,
|
||||||
|
@ -454,6 +457,7 @@ async function getMessageSendData({
|
||||||
preview,
|
preview,
|
||||||
quote,
|
quote,
|
||||||
sticker,
|
sticker,
|
||||||
|
storyContextTimestamp: message.get('sent_at'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -220,6 +220,7 @@ export async function sendReaction(
|
||||||
groupId: undefined,
|
groupId: undefined,
|
||||||
profileKey,
|
profileKey,
|
||||||
options: sendOptions,
|
options: sendOptions,
|
||||||
|
storyContextTimestamp: message.get('sent_at'),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
log.info('sending group reaction message');
|
log.info('sending group reaction message');
|
||||||
|
|
|
@ -9,7 +9,7 @@ import type {
|
||||||
QuotedMessageType,
|
QuotedMessageType,
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
import type { UUIDStringType } from '../types/UUID';
|
import type { UUIDStringType } from '../types/UUID';
|
||||||
import { isIncoming, isOutgoing } from '../state/selectors/message';
|
import { isIncoming, isOutgoing, isStory } from '../state/selectors/message';
|
||||||
|
|
||||||
export function isQuoteAMatch(
|
export function isQuoteAMatch(
|
||||||
message: MessageAttributesType | null | undefined,
|
message: MessageAttributesType | null | undefined,
|
||||||
|
@ -57,7 +57,7 @@ export function getContact(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSource(message: MessageAttributesType): string | undefined {
|
export function getSource(message: MessageAttributesType): string | undefined {
|
||||||
if (isIncoming(message)) {
|
if (isIncoming(message) || isStory(message)) {
|
||||||
return message.source;
|
return message.source;
|
||||||
}
|
}
|
||||||
if (!isOutgoing(message)) {
|
if (!isOutgoing(message)) {
|
||||||
|
@ -72,7 +72,7 @@ export function getSourceDevice(
|
||||||
): string | number | undefined {
|
): string | number | undefined {
|
||||||
const { sourceDevice } = message;
|
const { sourceDevice } = message;
|
||||||
|
|
||||||
if (isIncoming(message)) {
|
if (isIncoming(message) || isStory(message)) {
|
||||||
return sourceDevice;
|
return sourceDevice;
|
||||||
}
|
}
|
||||||
if (!isOutgoing(message)) {
|
if (!isOutgoing(message)) {
|
||||||
|
@ -87,7 +87,7 @@ export function getSourceDevice(
|
||||||
export function getSourceUuid(
|
export function getSourceUuid(
|
||||||
message: MessageAttributesType
|
message: MessageAttributesType
|
||||||
): UUIDStringType | undefined {
|
): UUIDStringType | undefined {
|
||||||
if (isIncoming(message)) {
|
if (isIncoming(message) || isStory(message)) {
|
||||||
return message.sourceUuid;
|
return message.sourceUuid;
|
||||||
}
|
}
|
||||||
if (!isOutgoing(message)) {
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as Backbone from 'backbone';
|
import * as Backbone from 'backbone';
|
||||||
|
@ -69,6 +69,8 @@ export type GroupMigrationType = {
|
||||||
invitedMembers: Array<GroupV2PendingMemberType>;
|
invitedMembers: Array<GroupV2PendingMemberType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PreviewMessageType = Array<WhatIsThis>;
|
||||||
|
|
||||||
export type QuotedMessageType = {
|
export type QuotedMessageType = {
|
||||||
attachments: Array<typeof window.WhatIsThis>;
|
attachments: Array<typeof window.WhatIsThis>;
|
||||||
// `author` is an old attribute that holds the author's E164. We shouldn't use it for
|
// `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;
|
messageId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StickerMessageType = {
|
||||||
|
packId: string;
|
||||||
|
stickerId: number;
|
||||||
|
packKey: string;
|
||||||
|
data?: AttachmentType;
|
||||||
|
};
|
||||||
|
|
||||||
export type RetryOptions = Readonly<{
|
export type RetryOptions = Readonly<{
|
||||||
type: 'session-reset';
|
type: 'session-reset';
|
||||||
uuid: string;
|
uuid: string;
|
||||||
|
@ -164,13 +173,8 @@ export type MessageAttributesType = {
|
||||||
| 'verified-change';
|
| 'verified-change';
|
||||||
body?: string;
|
body?: string;
|
||||||
attachments?: Array<AttachmentType>;
|
attachments?: Array<AttachmentType>;
|
||||||
preview?: Array<WhatIsThis>;
|
preview?: PreviewMessageType;
|
||||||
sticker?: {
|
sticker?: StickerMessageType;
|
||||||
packId: string;
|
|
||||||
stickerId: number;
|
|
||||||
packKey: string;
|
|
||||||
data?: AttachmentType;
|
|
||||||
};
|
|
||||||
sent_at: number;
|
sent_at: number;
|
||||||
unidentifiedDeliveries?: Array<string>;
|
unidentifiedDeliveries?: Array<string>;
|
||||||
contact?: Array<EmbeddedContactType>;
|
contact?: Array<EmbeddedContactType>;
|
||||||
|
@ -242,6 +246,7 @@ export type ConversationAttributesType = {
|
||||||
draftAttachments?: Array<AttachmentDraftType>;
|
draftAttachments?: Array<AttachmentDraftType>;
|
||||||
draftBodyRanges?: Array<BodyRangeType>;
|
draftBodyRanges?: Array<BodyRangeType>;
|
||||||
draftTimestamp?: number | null;
|
draftTimestamp?: number | null;
|
||||||
|
hideStory?: boolean;
|
||||||
inbox_position: number;
|
inbox_position: number;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
lastMessageDeletedForEveryone: boolean;
|
lastMessageDeletedForEveryone: boolean;
|
||||||
|
|
|
@ -1810,6 +1810,7 @@ export class ConversationModel extends window.Backbone
|
||||||
groupVersion,
|
groupVersion,
|
||||||
groupId: this.get('groupId'),
|
groupId: this.get('groupId'),
|
||||||
groupLink: this.getGroupLink(),
|
groupLink: this.getGroupLink(),
|
||||||
|
hideStory: Boolean(this.get('hideStory')),
|
||||||
inboxPosition,
|
inboxPosition,
|
||||||
isArchived: this.get('isArchived')!,
|
isArchived: this.get('isArchived')!,
|
||||||
isBlocked: this.isBlocked(),
|
isBlocked: this.isBlocked(),
|
||||||
|
@ -3790,10 +3791,12 @@ export class ConversationModel extends window.Backbone
|
||||||
{
|
{
|
||||||
dontClearDraft,
|
dontClearDraft,
|
||||||
sendHQImages,
|
sendHQImages,
|
||||||
|
storyId,
|
||||||
timestamp,
|
timestamp,
|
||||||
}: {
|
}: {
|
||||||
dontClearDraft?: boolean;
|
dontClearDraft?: boolean;
|
||||||
sendHQImages?: boolean;
|
sendHQImages?: boolean;
|
||||||
|
storyId?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
@ -3872,6 +3875,7 @@ export class ConversationModel extends window.Backbone
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
storyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isDirectConversation(this.attributes)) {
|
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(
|
setMuteExpiration(
|
||||||
muteExpiresAt = 0,
|
muteExpiresAt = 0,
|
||||||
{ viaStorageServiceSync = false } = {}
|
{ viaStorageServiceSync = false } = {}
|
||||||
|
|
|
@ -43,11 +43,6 @@ import * as expirationTimer from '../util/expirationTimer';
|
||||||
import type { ReactionType } from '../types/Reactions';
|
import type { ReactionType } from '../types/Reactions';
|
||||||
import { UUID, UUIDKind } from '../types/UUID';
|
import { UUID, UUIDKind } from '../types/UUID';
|
||||||
import * as reactionUtil from '../reactions/util';
|
import * as reactionUtil from '../reactions/util';
|
||||||
import {
|
|
||||||
copyStickerToAttachments,
|
|
||||||
savePackMetadata,
|
|
||||||
getStickerPackStatus,
|
|
||||||
} from '../types/Stickers';
|
|
||||||
import * as Stickers from '../types/Stickers';
|
import * as Stickers from '../types/Stickers';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import * as EmbeddedContact from '../types/EmbeddedContact';
|
import * as EmbeddedContact from '../types/EmbeddedContact';
|
||||||
|
@ -99,6 +94,7 @@ import {
|
||||||
isKeyChange,
|
isKeyChange,
|
||||||
isMessageHistoryUnsynced,
|
isMessageHistoryUnsynced,
|
||||||
isOutgoing,
|
isOutgoing,
|
||||||
|
isStory,
|
||||||
isProfileChange,
|
isProfileChange,
|
||||||
isTapToView,
|
isTapToView,
|
||||||
isUniversalTimerNotification,
|
isUniversalTimerNotification,
|
||||||
|
@ -124,7 +120,6 @@ import { ReactionSource } from '../reactions/ReactionSource';
|
||||||
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
|
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
|
||||||
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
|
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
|
||||||
import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
|
import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
|
||||||
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
|
||||||
import * as LinkPreview from '../types/LinkPreview';
|
import * as LinkPreview from '../types/LinkPreview';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
import {
|
import {
|
||||||
|
@ -141,13 +136,18 @@ import {
|
||||||
getContact,
|
getContact,
|
||||||
getContactId,
|
getContactId,
|
||||||
getSource,
|
getSource,
|
||||||
getSourceDevice,
|
|
||||||
getSourceUuid,
|
getSourceUuid,
|
||||||
isCustomError,
|
isCustomError,
|
||||||
isQuoteAMatch,
|
isQuoteAMatch,
|
||||||
} from '../messages/helpers';
|
} from '../messages/helpers';
|
||||||
import type { ReplacementValuesType } from '../types/I18N';
|
import type { ReplacementValuesType } from '../types/I18N';
|
||||||
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
|
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';
|
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
|
||||||
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
|
@ -165,7 +165,7 @@ window.Whisper = window.Whisper || {};
|
||||||
const { Message: TypedMessage } = window.Signal.Types;
|
const { Message: TypedMessage } = window.Signal.Types;
|
||||||
const { upgradeMessageSchema } = window.Signal.Migrations;
|
const { upgradeMessageSchema } = window.Signal.Migrations;
|
||||||
const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
|
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> {
|
export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
static getLongMessageAttachment: (
|
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
|
// Note: The clone is important for triggering a re-run of selectors
|
||||||
messageChanged(this.id, conversationId, { ...this.attributes });
|
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 {
|
getSenderIdentifier(): string {
|
||||||
|
@ -740,12 +767,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
|
||||||
// General
|
// General
|
||||||
idForLogging(): string {
|
idForLogging(): string {
|
||||||
const account =
|
return getMessageIdForLogging(this.attributes);
|
||||||
getSourceUuid(this.attributes) || getSource(this.attributes);
|
|
||||||
const device = getSourceDevice(this.attributes);
|
|
||||||
const timestamp = this.get('sent_at');
|
|
||||||
|
|
||||||
return `${account}.${device} ${timestamp}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override defaults(): Partial<MessageAttributesType> {
|
override defaults(): Partial<MessageAttributesType> {
|
||||||
|
@ -1636,332 +1658,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return getLastChallengeError(this.attributes);
|
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 {
|
hasAttachmentDownloads(): boolean {
|
||||||
const attachments = this.get('attachments') || [];
|
return hasAttachmentDownloads(this.attributes);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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> {
|
async queueAttachmentDownloads(): Promise<boolean> {
|
||||||
const attachmentsToQueue = this.get('attachments') || [];
|
const value = await queueAttachmentDownloads(this.attributes);
|
||||||
const messageId = this.id;
|
if (!value) {
|
||||||
let count = 0;
|
return false;
|
||||||
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()}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(
|
this.set(value);
|
||||||
`Queueing ${
|
return true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
markAttachmentAsCorrupted(attachment: AttachmentType): void {
|
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()}`
|
`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.
|
// First, check for duplicates. If we find one, stop processing here.
|
||||||
const inMemoryMessage = window.MessageController.findBySender(
|
const inMemoryMessage = window.MessageController.findBySender(
|
||||||
this.getSenderIdentifier()
|
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 = {
|
const withQuoteReference = {
|
||||||
...initialMessage,
|
...initialMessage,
|
||||||
quote: await this.copyFromQuotedMessage(
|
quote,
|
||||||
initialMessage.quote,
|
storyId: storyQuote?.id,
|
||||||
conversation.id
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
const dataMessage = await upgradeMessageSchema(withQuoteReference);
|
const dataMessage = await upgradeMessageSchema(withQuoteReference);
|
||||||
|
|
||||||
|
@ -2521,6 +2244,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
quote: dataMessage.quote,
|
quote: dataMessage.quote,
|
||||||
schemaVersion: dataMessage.schemaVersion,
|
schemaVersion: dataMessage.schemaVersion,
|
||||||
sticker: dataMessage.sticker,
|
sticker: dataMessage.sticker,
|
||||||
|
storyId: dataMessage.storyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSupported = !isUnsupportedMessage(message.attributes);
|
const isSupported = !isUnsupportedMessage(message.attributes);
|
||||||
|
@ -2807,8 +2531,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
conversation.incrementMessageCount();
|
conversation.incrementMessageCount();
|
||||||
window.Signal.Data.updateConversation(conversation.attributes);
|
window.Signal.Data.updateConversation(conversation.attributes);
|
||||||
|
|
||||||
// Only queue attachments for downloads if this is an outgoing message
|
// Only queue attachments for downloads if this is a story or
|
||||||
// or we've accepted the conversation
|
// outgoing message or we've accepted the conversation
|
||||||
const reduxState = window.reduxStore.getState();
|
const reduxState = window.reduxStore.getState();
|
||||||
const attachments = this.get('attachments') || [];
|
const attachments = this.get('attachments') || [];
|
||||||
const shouldHoldOffDownload =
|
const shouldHoldOffDownload =
|
||||||
|
@ -2818,6 +2542,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
this.hasAttachmentDownloads() &&
|
this.hasAttachmentDownloads() &&
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
(this.getConversation()!.getAccepted() ||
|
(this.getConversation()!.getAccepted() ||
|
||||||
|
isStory(message.attributes) ||
|
||||||
isOutgoing(message.attributes)) &&
|
isOutgoing(message.attributes)) &&
|
||||||
!shouldHoldOffDownload
|
!shouldHoldOffDownload
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -129,10 +129,7 @@ function generateStorageID(): Uint8Array {
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeneratedManifestType = {
|
type GeneratedManifestType = {
|
||||||
conversationsToUpdate: Array<{
|
postUploadUpdateFunctions: Array<() => unknown>;
|
||||||
conversation: ConversationModel;
|
|
||||||
storageID: string | undefined;
|
|
||||||
}>;
|
|
||||||
deleteKeys: Array<Uint8Array>;
|
deleteKeys: Array<Uint8Array>;
|
||||||
newItems: Set<Proto.IStorageItem>;
|
newItems: Set<Proto.IStorageItem>;
|
||||||
storageManifest: Proto.IStorageManifest;
|
storageManifest: Proto.IStorageManifest;
|
||||||
|
@ -152,7 +149,7 @@ async function generateManifest(
|
||||||
|
|
||||||
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
|
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
|
||||||
|
|
||||||
const conversationsToUpdate = [];
|
const postUploadUpdateFunctions: Array<() => unknown> = [];
|
||||||
const insertKeys: Array<string> = [];
|
const insertKeys: Array<string> = [];
|
||||||
const deleteKeys: Array<Uint8Array> = [];
|
const deleteKeys: Array<Uint8Array> = [];
|
||||||
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
|
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
|
||||||
|
@ -275,9 +272,13 @@ async function generateManifest(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
conversationsToUpdate.push({
|
postUploadUpdateFunctions.push(() => {
|
||||||
conversation,
|
conversation.set({
|
||||||
storageID,
|
needsStorageServiceSync: false,
|
||||||
|
storageVersion: version,
|
||||||
|
storageID,
|
||||||
|
});
|
||||||
|
updateConversation(conversation.attributes);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -510,7 +511,7 @@ async function generateManifest(
|
||||||
storageManifest.value = encryptedManifest;
|
storageManifest.value = encryptedManifest;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
conversationsToUpdate,
|
postUploadUpdateFunctions,
|
||||||
deleteKeys,
|
deleteKeys,
|
||||||
newItems,
|
newItems,
|
||||||
storageManifest,
|
storageManifest,
|
||||||
|
@ -520,7 +521,7 @@ async function generateManifest(
|
||||||
async function uploadManifest(
|
async function uploadManifest(
|
||||||
version: number,
|
version: number,
|
||||||
{
|
{
|
||||||
conversationsToUpdate,
|
postUploadUpdateFunctions,
|
||||||
deleteKeys,
|
deleteKeys,
|
||||||
newItems,
|
newItems,
|
||||||
storageManifest,
|
storageManifest,
|
||||||
|
@ -556,18 +557,11 @@ async function uploadManifest(
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`storageService.upload(${version}): upload complete, updating ` +
|
`storageService.upload(${version}): upload complete, updating ` +
|
||||||
`conversations=${conversationsToUpdate.length}`
|
`items=${postUploadUpdateFunctions.length}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// update conversations with the new storageID
|
// update conversations with the new storageID
|
||||||
conversationsToUpdate.forEach(({ conversation, storageID }) => {
|
postUploadUpdateFunctions.forEach(fn => fn());
|
||||||
conversation.set({
|
|
||||||
needsStorageServiceSync: false,
|
|
||||||
storageVersion: version,
|
|
||||||
storageID,
|
|
||||||
});
|
|
||||||
updateConversation(conversation.attributes);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(
|
log.error(
|
||||||
`storageService.upload(${version}): failed!`,
|
`storageService.upload(${version}): failed!`,
|
||||||
|
@ -655,11 +649,11 @@ async function createNewManifest() {
|
||||||
|
|
||||||
const version = window.storage.get('manifestVersion', 0);
|
const version = window.storage.get('manifestVersion', 0);
|
||||||
|
|
||||||
const { conversationsToUpdate, newItems, storageManifest } =
|
const { postUploadUpdateFunctions, newItems, storageManifest } =
|
||||||
await generateManifest(version, undefined, true);
|
await generateManifest(version, undefined, true);
|
||||||
|
|
||||||
await uploadManifest(version, {
|
await uploadManifest(version, {
|
||||||
conversationsToUpdate,
|
postUploadUpdateFunctions,
|
||||||
// we have created a new manifest, there should be no keys to delete
|
// we have created a new manifest, there should be no keys to delete
|
||||||
deleteKeys: [],
|
deleteKeys: [],
|
||||||
newItems,
|
newItems,
|
||||||
|
|
|
@ -154,15 +154,18 @@ export async function toContactRecord(
|
||||||
contactRecord.mutedUntilTimestamp = getSafeLongFromTimestamp(
|
contactRecord.mutedUntilTimestamp = getSafeLongFromTimestamp(
|
||||||
conversation.get('muteExpiresAt')
|
conversation.get('muteExpiresAt')
|
||||||
);
|
);
|
||||||
|
if (conversation.get('hideStory') !== undefined) {
|
||||||
|
contactRecord.hideStory = Boolean(conversation.get('hideStory'));
|
||||||
|
}
|
||||||
|
|
||||||
applyUnknownFields(contactRecord, conversation);
|
applyUnknownFields(contactRecord, conversation);
|
||||||
|
|
||||||
return contactRecord;
|
return contactRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toAccountRecord(
|
export function toAccountRecord(
|
||||||
conversation: ConversationModel
|
conversation: ConversationModel
|
||||||
): Promise<Proto.AccountRecord> {
|
): Proto.AccountRecord {
|
||||||
const accountRecord = new Proto.AccountRecord();
|
const accountRecord = new Proto.AccountRecord();
|
||||||
|
|
||||||
if (conversation.get('profileKey')) {
|
if (conversation.get('profileKey')) {
|
||||||
|
@ -319,9 +322,9 @@ export async function toAccountRecord(
|
||||||
return accountRecord;
|
return accountRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toGroupV1Record(
|
export function toGroupV1Record(
|
||||||
conversation: ConversationModel
|
conversation: ConversationModel
|
||||||
): Promise<Proto.GroupV1Record> {
|
): Proto.GroupV1Record {
|
||||||
const groupV1Record = new Proto.GroupV1Record();
|
const groupV1Record = new Proto.GroupV1Record();
|
||||||
|
|
||||||
groupV1Record.id = Bytes.fromBinary(String(conversation.get('groupId')));
|
groupV1Record.id = Bytes.fromBinary(String(conversation.get('groupId')));
|
||||||
|
@ -338,9 +341,9 @@ export async function toGroupV1Record(
|
||||||
return groupV1Record;
|
return groupV1Record;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toGroupV2Record(
|
export function toGroupV2Record(
|
||||||
conversation: ConversationModel
|
conversation: ConversationModel
|
||||||
): Promise<Proto.GroupV2Record> {
|
): Proto.GroupV2Record {
|
||||||
const groupV2Record = new Proto.GroupV2Record();
|
const groupV2Record = new Proto.GroupV2Record();
|
||||||
|
|
||||||
const masterKey = conversation.get('masterKey');
|
const masterKey = conversation.get('masterKey');
|
||||||
|
@ -357,6 +360,7 @@ export async function toGroupV2Record(
|
||||||
groupV2Record.dontNotifyForMentionsIfMuted = Boolean(
|
groupV2Record.dontNotifyForMentionsIfMuted = Boolean(
|
||||||
conversation.get('dontNotifyForMentionsIfMuted')
|
conversation.get('dontNotifyForMentionsIfMuted')
|
||||||
);
|
);
|
||||||
|
groupV2Record.hideStory = Boolean(conversation.get('hideStory'));
|
||||||
|
|
||||||
applyUnknownFields(groupV2Record, conversation);
|
applyUnknownFields(groupV2Record, conversation);
|
||||||
|
|
||||||
|
@ -592,7 +596,7 @@ export async function mergeGroupV1Record(
|
||||||
addUnknownFields(groupV1Record, conversation, details);
|
addUnknownFields(groupV1Record, conversation, details);
|
||||||
|
|
||||||
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
|
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
|
||||||
await toGroupV1Record(conversation),
|
toGroupV1Record(conversation),
|
||||||
groupV1Record,
|
groupV1Record,
|
||||||
conversation
|
conversation
|
||||||
);
|
);
|
||||||
|
@ -683,6 +687,7 @@ export async function mergeGroupV2Record(
|
||||||
const oldStorageVersion = conversation.get('storageVersion');
|
const oldStorageVersion = conversation.get('storageVersion');
|
||||||
|
|
||||||
conversation.set({
|
conversation.set({
|
||||||
|
hideStory: Boolean(groupV2Record.hideStory),
|
||||||
isArchived: Boolean(groupV2Record.archived),
|
isArchived: Boolean(groupV2Record.archived),
|
||||||
markedUnread: Boolean(groupV2Record.markedUnread),
|
markedUnread: Boolean(groupV2Record.markedUnread),
|
||||||
dontNotifyForMentionsIfMuted: Boolean(
|
dontNotifyForMentionsIfMuted: Boolean(
|
||||||
|
@ -706,7 +711,7 @@ export async function mergeGroupV2Record(
|
||||||
addUnknownFields(groupV2Record, conversation, details);
|
addUnknownFields(groupV2Record, conversation, details);
|
||||||
|
|
||||||
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
|
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
|
||||||
await toGroupV2Record(conversation),
|
toGroupV2Record(conversation),
|
||||||
groupV2Record,
|
groupV2Record,
|
||||||
conversation
|
conversation
|
||||||
);
|
);
|
||||||
|
@ -852,6 +857,7 @@ export async function mergeContactRecord(
|
||||||
const oldStorageVersion = conversation.get('storageVersion');
|
const oldStorageVersion = conversation.get('storageVersion');
|
||||||
|
|
||||||
conversation.set({
|
conversation.set({
|
||||||
|
hideStory: Boolean(contactRecord.hideStory),
|
||||||
isArchived: Boolean(contactRecord.archived),
|
isArchived: Boolean(contactRecord.archived),
|
||||||
markedUnread: Boolean(contactRecord.markedUnread),
|
markedUnread: Boolean(contactRecord.markedUnread),
|
||||||
storageID,
|
storageID,
|
||||||
|
@ -1142,7 +1148,7 @@ export async function mergeAccountRecord(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
|
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
|
||||||
await toAccountRecord(conversation),
|
toAccountRecord(conversation),
|
||||||
accountRecord,
|
accountRecord,
|
||||||
conversation
|
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,
|
_deleteAllStoryDistributions,
|
||||||
createNewStoryDistribution,
|
createNewStoryDistribution,
|
||||||
getAllStoryDistributionsWithMembers,
|
getAllStoryDistributionsWithMembers,
|
||||||
|
getStoryDistributionWithMembers,
|
||||||
modifyStoryDistribution,
|
modifyStoryDistribution,
|
||||||
modifyStoryDistributionMembers,
|
modifyStoryDistributionMembers,
|
||||||
deleteStoryDistribution,
|
deleteStoryDistribution,
|
||||||
|
@ -1583,6 +1584,11 @@ async function getAllStoryDistributionsWithMembers(): Promise<
|
||||||
> {
|
> {
|
||||||
return channels.getAllStoryDistributionsWithMembers();
|
return channels.getAllStoryDistributionsWithMembers();
|
||||||
}
|
}
|
||||||
|
async function getStoryDistributionWithMembers(
|
||||||
|
id: string
|
||||||
|
): Promise<StoryDistributionWithMembersType | undefined> {
|
||||||
|
return channels.getStoryDistributionWithMembers(id);
|
||||||
|
}
|
||||||
async function modifyStoryDistribution(
|
async function modifyStoryDistribution(
|
||||||
distribution: StoryDistributionType
|
distribution: StoryDistributionType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
|
@ -536,6 +536,9 @@ export type DataInterface = {
|
||||||
getAllStoryDistributionsWithMembers(): Promise<
|
getAllStoryDistributionsWithMembers(): Promise<
|
||||||
Array<StoryDistributionWithMembersType>
|
Array<StoryDistributionWithMembersType>
|
||||||
>;
|
>;
|
||||||
|
getStoryDistributionWithMembers(
|
||||||
|
id: string
|
||||||
|
): Promise<StoryDistributionWithMembersType | undefined>;
|
||||||
modifyStoryDistribution(distribution: StoryDistributionType): Promise<void>;
|
modifyStoryDistribution(distribution: StoryDistributionType): Promise<void>;
|
||||||
modifyStoryDistributionMembers(
|
modifyStoryDistributionMembers(
|
||||||
id: string,
|
id: string,
|
||||||
|
|
|
@ -281,6 +281,7 @@ const dataInterface: ServerInterface = {
|
||||||
_deleteAllStoryDistributions,
|
_deleteAllStoryDistributions,
|
||||||
createNewStoryDistribution,
|
createNewStoryDistribution,
|
||||||
getAllStoryDistributionsWithMembers,
|
getAllStoryDistributionsWithMembers,
|
||||||
|
getStoryDistributionWithMembers,
|
||||||
modifyStoryDistribution,
|
modifyStoryDistribution,
|
||||||
modifyStoryDistributionMembers,
|
modifyStoryDistributionMembers,
|
||||||
deleteStoryDistribution,
|
deleteStoryDistribution,
|
||||||
|
@ -3965,6 +3966,33 @@ async function getAllStoryDistributionsWithMembers(): Promise<
|
||||||
members: (byListId[list.id] || []).map(member => member.uuid),
|
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(
|
async function modifyStoryDistribution(
|
||||||
distribution: StoryDistributionType
|
distribution: StoryDistributionType
|
||||||
): Promise<void> {
|
): 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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { actions as accounts } from './ducks/accounts';
|
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 safetyNumber } from './ducks/safetyNumber';
|
||||||
import { actions as search } from './ducks/search';
|
import { actions as search } from './ducks/search';
|
||||||
import { actions as stickers } from './ducks/stickers';
|
import { actions as stickers } from './ducks/stickers';
|
||||||
|
import { actions as stories } from './ducks/stories';
|
||||||
import { actions as updates } from './ducks/updates';
|
import { actions as updates } from './ducks/updates';
|
||||||
import { actions as user } from './ducks/user';
|
import { actions as user } from './ducks/user';
|
||||||
import type { ReduxActions } from './types';
|
import type { ReduxActions } from './types';
|
||||||
|
@ -42,6 +43,7 @@ export const actionCreators: ReduxActions = {
|
||||||
safetyNumber,
|
safetyNumber,
|
||||||
search,
|
search,
|
||||||
stickers,
|
stickers,
|
||||||
|
stories,
|
||||||
updates,
|
updates,
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
|
@ -65,6 +67,7 @@ export const mapDispatchToProps = {
|
||||||
...safetyNumber,
|
...safetyNumber,
|
||||||
...search,
|
...search,
|
||||||
...stickers,
|
...stickers,
|
||||||
|
...stories,
|
||||||
...updates,
|
...updates,
|
||||||
...user,
|
...user,
|
||||||
};
|
};
|
||||||
|
|
|
@ -78,6 +78,7 @@ import { showToast } from '../../util/showToast';
|
||||||
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
|
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
|
||||||
import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchUsername';
|
import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchUsername';
|
||||||
import { isValidUsername } from '../../types/Username';
|
import { isValidUsername } from '../../types/Username';
|
||||||
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
|
|
||||||
import type { NoopActionType } from './noop';
|
import type { NoopActionType } from './noop';
|
||||||
import { conversationJobQueue } from '../../jobs/conversationJobQueue';
|
import { conversationJobQueue } from '../../jobs/conversationJobQueue';
|
||||||
|
@ -130,6 +131,7 @@ export type ConversationType = {
|
||||||
customColor?: CustomColorType;
|
customColor?: CustomColorType;
|
||||||
customColorId?: string;
|
customColorId?: string;
|
||||||
discoveredUnregisteredAt?: number;
|
discoveredUnregisteredAt?: number;
|
||||||
|
hideStory?: boolean;
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
isBlocked?: boolean;
|
isBlocked?: boolean;
|
||||||
isGroupV1AndDisabled?: boolean;
|
isGroupV1AndDisabled?: boolean;
|
||||||
|
@ -851,10 +853,14 @@ export const actions = {
|
||||||
toggleAdmin,
|
toggleAdmin,
|
||||||
toggleConversationInChooseMembers,
|
toggleConversationInChooseMembers,
|
||||||
toggleComposeEditingAvatar,
|
toggleComposeEditingAvatar,
|
||||||
|
toggleHideStories,
|
||||||
updateConversationModelSharedGroups,
|
updateConversationModelSharedGroups,
|
||||||
verifyConversationsStoppingSend,
|
verifyConversationsStoppingSend,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useConversationsActions = (): typeof actions =>
|
||||||
|
useBoundActions(actions);
|
||||||
|
|
||||||
function filterAvatarData(
|
function filterAvatarData(
|
||||||
avatars: ReadonlyArray<AvatarDataType>,
|
avatars: ReadonlyArray<AvatarDataType>,
|
||||||
data: 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(
|
function removeMemberFromGroup(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
contactId: 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 preferredReactions } from './ducks/preferredReactions';
|
||||||
import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
|
import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
|
||||||
import { getEmptyState as search } from './ducks/search';
|
import { getEmptyState as search } from './ducks/search';
|
||||||
|
import { getEmptyState as getStoriesEmptyState } from './ducks/stories';
|
||||||
import { getEmptyState as updates } from './ducks/updates';
|
import { getEmptyState as updates } from './ducks/updates';
|
||||||
import { getEmptyState as user } from './ducks/user';
|
import { getEmptyState as user } from './ducks/user';
|
||||||
|
|
||||||
import type { StateType } from './reducer';
|
import type { StateType } from './reducer';
|
||||||
|
|
||||||
import type { BadgesStateType } from './ducks/badges';
|
import type { BadgesStateType } from './ducks/badges';
|
||||||
|
import type { StoryDataType } from './ducks/stories';
|
||||||
import { getInitialState as stickers } from '../types/Stickers';
|
import { getInitialState as stickers } from '../types/Stickers';
|
||||||
import { getEmojiReducerState as emojis } from '../util/loadRecentEmojis';
|
import { getEmojiReducerState as emojis } from '../util/loadRecentEmojis';
|
||||||
|
|
||||||
export function getInitialState({
|
export function getInitialState({
|
||||||
badges,
|
badges,
|
||||||
|
stories,
|
||||||
}: {
|
}: {
|
||||||
badges: BadgesStateType;
|
badges: BadgesStateType;
|
||||||
|
stories: Array<StoryDataType>;
|
||||||
}): StateType {
|
}): StateType {
|
||||||
const items = window.storage.getItemsState();
|
const items = window.storage.getItemsState();
|
||||||
|
|
||||||
|
@ -87,6 +91,10 @@ export function getInitialState({
|
||||||
safetyNumber: safetyNumber(),
|
safetyNumber: safetyNumber(),
|
||||||
search: search(),
|
search: search(),
|
||||||
stickers: stickers(),
|
stickers: stickers(),
|
||||||
|
stories: {
|
||||||
|
...getStoriesEmptyState(),
|
||||||
|
stories,
|
||||||
|
},
|
||||||
updates: updates(),
|
updates: updates(),
|
||||||
user: {
|
user: {
|
||||||
...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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { combineReducers } from 'redux';
|
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 safetyNumber } from './ducks/safetyNumber';
|
||||||
import { reducer as search } from './ducks/search';
|
import { reducer as search } from './ducks/search';
|
||||||
import { reducer as stickers } from './ducks/stickers';
|
import { reducer as stickers } from './ducks/stickers';
|
||||||
|
import { reducer as stories } from './ducks/stories';
|
||||||
import { reducer as updates } from './ducks/updates';
|
import { reducer as updates } from './ducks/updates';
|
||||||
import { reducer as user } from './ducks/user';
|
import { reducer as user } from './ducks/user';
|
||||||
|
|
||||||
|
@ -45,6 +46,7 @@ export const reducer = combineReducers({
|
||||||
safetyNumber,
|
safetyNumber,
|
||||||
search,
|
search,
|
||||||
stickers,
|
stickers,
|
||||||
|
stories,
|
||||||
updates,
|
updates,
|
||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
|
|
|
@ -58,6 +58,13 @@ export const getUsernamesEnabled = createSelector(
|
||||||
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.usernames')
|
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.usernames')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getStoriesEnabled = createSelector(
|
||||||
|
getRemoteConfig,
|
||||||
|
(remoteConfig: ConfigMapType): boolean =>
|
||||||
|
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.internalUser') ||
|
||||||
|
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.stories')
|
||||||
|
);
|
||||||
|
|
||||||
export const getDefaultConversationColor = createSelector(
|
export const getDefaultConversationColor = createSelector(
|
||||||
getItems,
|
getItems,
|
||||||
(
|
(
|
||||||
|
|
|
@ -129,6 +129,12 @@ export function isOutgoing(
|
||||||
return message.type === 'outgoing';
|
return message.type === 'outgoing';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isStory(
|
||||||
|
message: Pick<MessageWithUIFieldsType, 'type'>
|
||||||
|
): boolean {
|
||||||
|
return message.type === 'story';
|
||||||
|
}
|
||||||
|
|
||||||
export function hasErrors(
|
export function hasErrors(
|
||||||
message: Pick<MessageWithUIFieldsType, 'errors'>
|
message: Pick<MessageWithUIFieldsType, 'errors'>
|
||||||
): boolean {
|
): 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;
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
@ -9,9 +9,11 @@ import { SmartCallManager } from './CallManager';
|
||||||
import { SmartCustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal';
|
import { SmartCustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal';
|
||||||
import { SmartGlobalModalContainer } from './GlobalModalContainer';
|
import { SmartGlobalModalContainer } from './GlobalModalContainer';
|
||||||
import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
|
import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
|
||||||
|
import { SmartStories } from './Stories';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
import { getIntl, getTheme } from '../selectors/user';
|
import { getIntl, getTheme } from '../selectors/user';
|
||||||
|
import { shouldShowStoriesView } from '../selectors/stories';
|
||||||
import { getConversationsStoppingSend } from '../selectors/conversations';
|
import { getConversationsStoppingSend } from '../selectors/conversations';
|
||||||
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
|
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { mapDispatchToProps } from '../actions';
|
||||||
|
@ -32,6 +34,8 @@ const mapStateToProps = (state: StateType) => {
|
||||||
renderSafetyNumber: (props: SafetyNumberProps) => (
|
renderSafetyNumber: (props: SafetyNumberProps) => (
|
||||||
<SmartSafetyNumberViewer {...props} />
|
<SmartSafetyNumberViewer {...props} />
|
||||||
),
|
),
|
||||||
|
isShowingStoriesView: shouldShowStoriesView(state),
|
||||||
|
renderStories: () => <SmartStories />,
|
||||||
requestVerification: (
|
requestVerification: (
|
||||||
type: 'sms' | 'voice',
|
type: 'sms' | 'voice',
|
||||||
number: string,
|
number: string,
|
||||||
|
|
|
@ -17,11 +17,13 @@ import {
|
||||||
getUserUuid,
|
getUserUuid,
|
||||||
} from '../selectors/user';
|
} from '../selectors/user';
|
||||||
import { getMe } from '../selectors/conversations';
|
import { getMe } from '../selectors/conversations';
|
||||||
|
import { getStoriesEnabled } from '../selectors/items';
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType) => {
|
const mapStateToProps = (state: StateType) => {
|
||||||
const me = getMe(state);
|
const me = getMe(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
areStoriesEnabled: getStoriesEnabled(state),
|
||||||
hasPendingUpdate: Boolean(state.updates.didSnooze),
|
hasPendingUpdate: Boolean(state.updates.didSnooze),
|
||||||
regionCode: getRegionCode(state),
|
regionCode: getRegionCode(state),
|
||||||
ourConversationId: getUserConversationId(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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { actions as accounts } from './ducks/accounts';
|
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 safetyNumber } from './ducks/safetyNumber';
|
||||||
import type { actions as search } from './ducks/search';
|
import type { actions as search } from './ducks/search';
|
||||||
import type { actions as stickers } from './ducks/stickers';
|
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 updates } from './ducks/updates';
|
||||||
import type { actions as user } from './ducks/user';
|
import type { actions as user } from './ducks/user';
|
||||||
|
|
||||||
|
@ -41,6 +42,7 @@ export type ReduxActions = {
|
||||||
safetyNumber: typeof safetyNumber;
|
safetyNumber: typeof safetyNumber;
|
||||||
search: typeof search;
|
search: typeof search;
|
||||||
stickers: typeof stickers;
|
stickers: typeof stickers;
|
||||||
|
stories: typeof stories;
|
||||||
updates: typeof updates;
|
updates: typeof updates;
|
||||||
user: typeof user;
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AttachmentType,
|
AttachmentType,
|
||||||
AttachmentDraftType,
|
AttachmentDraftType,
|
||||||
|
ThumbnailType,
|
||||||
} from '../../types/Attachment';
|
} from '../../types/Attachment';
|
||||||
import { IMAGE_JPEG } from '../../types/MIME';
|
import { IMAGE_JPEG } from '../../types/MIME';
|
||||||
|
|
||||||
|
@ -17,6 +18,14 @@ export const fakeAttachment = (
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const fakeThumbnail = (url: string): ThumbnailType => ({
|
||||||
|
contentType: IMAGE_JPEG,
|
||||||
|
height: 100,
|
||||||
|
path: url,
|
||||||
|
url,
|
||||||
|
width: 100,
|
||||||
|
});
|
||||||
|
|
||||||
export const fakeDraftAttachment = (
|
export const fakeDraftAttachment = (
|
||||||
overrides: Partial<AttachmentDraftType> = {}
|
overrides: Partial<AttachmentDraftType> = {}
|
||||||
): AttachmentDraftType => ({
|
): AttachmentDraftType => ({
|
||||||
|
|
|
@ -317,6 +317,13 @@ const LAST_NAMES = [
|
||||||
export const getFirstName = (): string => sample(FIRST_NAMES) || 'Test';
|
export const getFirstName = (): string => sample(FIRST_NAMES) || 'Test';
|
||||||
export const getLastName = (): string => sample(LAST_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(
|
export function getDefaultConversation(
|
||||||
overrideProps: Partial<ConversationType> = {}
|
overrideProps: Partial<ConversationType> = {}
|
||||||
): ConversationType {
|
): ConversationType {
|
||||||
|
@ -325,6 +332,7 @@ export function getDefaultConversation(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
acceptedMessageRequest: true,
|
acceptedMessageRequest: true,
|
||||||
|
avatarPath: getAvatarPath(),
|
||||||
badges: [],
|
badges: [],
|
||||||
e164: '+1300555000',
|
e164: '+1300555000',
|
||||||
color: getRandomColor(),
|
color: getRandomColor(),
|
||||||
|
|
|
@ -72,6 +72,7 @@ import type { Storage } from './Storage';
|
||||||
import { WarnOnlyError } from './Errors';
|
import { WarnOnlyError } from './Errors';
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import type {
|
import type {
|
||||||
|
ProcessedAttachment,
|
||||||
ProcessedDataMessage,
|
ProcessedDataMessage,
|
||||||
ProcessedSyncMessage,
|
ProcessedSyncMessage,
|
||||||
ProcessedSent,
|
ProcessedSent,
|
||||||
|
@ -107,6 +108,7 @@ import {
|
||||||
GroupSyncEvent,
|
GroupSyncEvent,
|
||||||
} from './messageReceiverEvents';
|
} from './messageReceiverEvents';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
import * as durations from '../util/durations';
|
||||||
import { areArraysMatchingSets } from '../util/areArraysMatchingSets';
|
import { areArraysMatchingSets } from '../util/areArraysMatchingSets';
|
||||||
|
|
||||||
const GROUPV1_ID_LENGTH = 16;
|
const GROUPV1_ID_LENGTH = 16;
|
||||||
|
@ -1787,6 +1789,66 @@ export default class MessageReceiver
|
||||||
return this.dispatchAndWait(ev);
|
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(
|
private async handleDataMessage(
|
||||||
envelope: UnsealedEnvelope,
|
envelope: UnsealedEnvelope,
|
||||||
msg: Proto.IDataMessage
|
msg: Proto.IDataMessage
|
||||||
|
@ -1794,14 +1856,6 @@ export default class MessageReceiver
|
||||||
const logId = this.getEnvelopeId(envelope);
|
const logId = this.getEnvelopeId(envelope);
|
||||||
log.info('MessageReceiver.handleDataMessage', logId);
|
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();
|
let p: Promise<void> = Promise.resolve();
|
||||||
// eslint-disable-next-line no-bitwise
|
// eslint-disable-next-line no-bitwise
|
||||||
const destination = envelope.sourceUuid;
|
const destination = envelope.sourceUuid;
|
||||||
|
@ -1993,11 +2047,7 @@ export default class MessageReceiver
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (content.storyMessage) {
|
if (content.storyMessage) {
|
||||||
const logId = this.getEnvelopeId(envelope);
|
await this.handleStoryMessage(envelope, content.storyMessage);
|
||||||
log.info(
|
|
||||||
`innerHandleContentMessage/${logId}: Dropping incoming message with storyMessage field`
|
|
||||||
);
|
|
||||||
this.removeFromCache(envelope);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -191,6 +191,7 @@ export type MessageOptionsType = {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
mentions?: BodyRangesType;
|
mentions?: BodyRangesType;
|
||||||
groupCallUpdate?: GroupCallUpdateType;
|
groupCallUpdate?: GroupCallUpdateType;
|
||||||
|
storyContextTimestamp?: number;
|
||||||
};
|
};
|
||||||
export type GroupSendOptionsType = {
|
export type GroupSendOptionsType = {
|
||||||
attachments?: Array<AttachmentType>;
|
attachments?: Array<AttachmentType>;
|
||||||
|
@ -208,6 +209,7 @@ export type GroupSendOptionsType = {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
mentions?: BodyRangesType;
|
mentions?: BodyRangesType;
|
||||||
groupCallUpdate?: GroupCallUpdateType;
|
groupCallUpdate?: GroupCallUpdateType;
|
||||||
|
storyContextTimestamp?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Message {
|
class Message {
|
||||||
|
@ -252,6 +254,8 @@ class Message {
|
||||||
|
|
||||||
groupCallUpdate?: GroupCallUpdateType;
|
groupCallUpdate?: GroupCallUpdateType;
|
||||||
|
|
||||||
|
storyContextTimestamp?: number;
|
||||||
|
|
||||||
constructor(options: MessageOptionsType) {
|
constructor(options: MessageOptionsType) {
|
||||||
this.attachments = options.attachments || [];
|
this.attachments = options.attachments || [];
|
||||||
this.body = options.body;
|
this.body = options.body;
|
||||||
|
@ -270,6 +274,7 @@ class Message {
|
||||||
this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp;
|
this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp;
|
||||||
this.mentions = options.mentions;
|
this.mentions = options.mentions;
|
||||||
this.groupCallUpdate = options.groupCallUpdate;
|
this.groupCallUpdate = options.groupCallUpdate;
|
||||||
|
this.storyContextTimestamp = options.storyContextTimestamp;
|
||||||
|
|
||||||
if (!(this.recipients instanceof Array)) {
|
if (!(this.recipients instanceof Array)) {
|
||||||
throw new Error('Invalid recipient list');
|
throw new Error('Invalid recipient list');
|
||||||
|
@ -470,6 +475,18 @@ class Message {
|
||||||
proto.groupCallUpdate = groupCallUpdate;
|
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;
|
this.dataMessage = proto;
|
||||||
return proto;
|
return proto;
|
||||||
}
|
}
|
||||||
|
@ -779,6 +796,7 @@ export default class MessageSender {
|
||||||
quote,
|
quote,
|
||||||
reaction,
|
reaction,
|
||||||
sticker,
|
sticker,
|
||||||
|
storyContextTimestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
@ -833,6 +851,7 @@ export default class MessageSender {
|
||||||
reaction,
|
reaction,
|
||||||
recipients,
|
recipients,
|
||||||
sticker,
|
sticker,
|
||||||
|
storyContextTimestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1024,6 +1043,7 @@ export default class MessageSender {
|
||||||
groupId,
|
groupId,
|
||||||
profileKey,
|
profileKey,
|
||||||
options,
|
options,
|
||||||
|
storyContextTimestamp,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
identifier: string;
|
identifier: string;
|
||||||
messageText: string | undefined;
|
messageText: string | undefined;
|
||||||
|
@ -1038,6 +1058,7 @@ export default class MessageSender {
|
||||||
contentHint: number;
|
contentHint: number;
|
||||||
groupId: string | undefined;
|
groupId: string | undefined;
|
||||||
profileKey?: Uint8Array;
|
profileKey?: Uint8Array;
|
||||||
|
storyContextTimestamp?: number;
|
||||||
options?: SendOptionsType;
|
options?: SendOptionsType;
|
||||||
}>): Promise<CallbackResultType> {
|
}>): Promise<CallbackResultType> {
|
||||||
return this.sendMessage({
|
return this.sendMessage({
|
||||||
|
@ -1053,6 +1074,7 @@ export default class MessageSender {
|
||||||
deletedForEveryoneTimestamp,
|
deletedForEveryoneTimestamp,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
profileKey,
|
profileKey,
|
||||||
|
storyContextTimestamp,
|
||||||
},
|
},
|
||||||
contentHint,
|
contentHint,
|
||||||
groupId,
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { SignalService as Proto } from '../protobuf';
|
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 ProcessedGroupCallUpdate = Proto.DataMessage.IGroupCallUpdate;
|
||||||
|
|
||||||
|
export type ProcessedStoryContext = Proto.DataMessage.IStoryContext;
|
||||||
|
|
||||||
export type ProcessedDataMessage = {
|
export type ProcessedDataMessage = {
|
||||||
body?: string;
|
body?: string;
|
||||||
attachments: ReadonlyArray<ProcessedAttachment>;
|
attachments: ReadonlyArray<ProcessedAttachment>;
|
||||||
|
@ -197,11 +199,13 @@ export type ProcessedDataMessage = {
|
||||||
preview?: ReadonlyArray<ProcessedPreview>;
|
preview?: ReadonlyArray<ProcessedPreview>;
|
||||||
sticker?: ProcessedSticker;
|
sticker?: ProcessedSticker;
|
||||||
requiredProtocolVersion?: number;
|
requiredProtocolVersion?: number;
|
||||||
|
isStory?: boolean;
|
||||||
isViewOnce: boolean;
|
isViewOnce: boolean;
|
||||||
reaction?: ProcessedReaction;
|
reaction?: ProcessedReaction;
|
||||||
delete?: ProcessedDelete;
|
delete?: ProcessedDelete;
|
||||||
bodyRanges?: ReadonlyArray<ProcessedBodyRange>;
|
bodyRanges?: ReadonlyArray<ProcessedBodyRange>;
|
||||||
groupCallUpdate?: ProcessedGroupCallUpdate;
|
groupCallUpdate?: ProcessedGroupCallUpdate;
|
||||||
|
storyContext?: ProcessedStoryContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProcessedUnidentifiedDeliveryStatus = Omit<
|
export type ProcessedUnidentifiedDeliveryStatus = Omit<
|
||||||
|
|
|
@ -661,6 +661,7 @@ export type CapabilitiesType = {
|
||||||
'gv1-migration': boolean;
|
'gv1-migration': boolean;
|
||||||
senderKey: boolean;
|
senderKey: boolean;
|
||||||
changeNumber: boolean;
|
changeNumber: boolean;
|
||||||
|
stories: boolean;
|
||||||
};
|
};
|
||||||
export type CapabilitiesUploadType = {
|
export type CapabilitiesUploadType = {
|
||||||
announcementGroup: true;
|
announcementGroup: true;
|
||||||
|
@ -668,6 +669,7 @@ export type CapabilitiesUploadType = {
|
||||||
'gv1-migration': true;
|
'gv1-migration': true;
|
||||||
senderKey: true;
|
senderKey: true;
|
||||||
changeNumber: true;
|
changeNumber: true;
|
||||||
|
stories: true;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StickerPackManifestType = Uint8Array;
|
type StickerPackManifestType = Uint8Array;
|
||||||
|
@ -1726,6 +1728,7 @@ export function initialize({
|
||||||
'gv1-migration': true,
|
'gv1-migration': true,
|
||||||
senderKey: true,
|
senderKey: true,
|
||||||
changeNumber: true,
|
changeNumber: true,
|
||||||
|
stories: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { accessKey } = options;
|
const { accessKey } = options;
|
||||||
|
|
|
@ -280,6 +280,7 @@ export async function processDataMessage(
|
||||||
delete: processDelete(message.delete),
|
delete: processDelete(message.delete),
|
||||||
bodyRanges: message.bodyRanges ?? [],
|
bodyRanges: message.bodyRanges ?? [],
|
||||||
groupCallUpdate: dropNull(message.groupCallUpdate),
|
groupCallUpdate: dropNull(message.groupCallUpdate),
|
||||||
|
storyContext: dropNull(message.storyContext),
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEndSession = Boolean(result.flags & FLAGS.END_SESSION);
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
export const AvatarColorMap = new Map([
|
export const AvatarColorMap = new Map([
|
||||||
|
@ -184,3 +184,7 @@ export type CustomColorsItemType = {
|
||||||
readonly colors: Record<string, CustomColorType>;
|
readonly colors: Record<string, CustomColorType>;
|
||||||
readonly version: number;
|
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",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-11-30T10:15:33.662Z"
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/Tooltip.tsx",
|
"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