From eb91eb6fec79e4af37d0ae04ae93e213a030cff5 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 4 Mar 2022 16:14:52 -0500 Subject: [PATCH] Process incoming story messages --- _locales/en/messages.json | 151 +++++++ images/icons/v2/add-reaction-outline-24.svg | 1 + images/icons/v2/messages-solid-20.svg | 1 + images/icons/v2/open-24.svg | 1 + images/icons/v2/stories-outline-24.svg | 1 + images/icons/v2/stories-outline-56.svg | 1 + protos/SignalService.proto | 32 +- protos/SignalStorage.proto | 4 +- stylesheets/_modules.scss | 76 +++- stylesheets/components/Avatar.scss | 12 +- stylesheets/components/MyStories.scss | 111 +++++ stylesheets/components/Stories.scss | 134 ++++++ stylesheets/components/StoryListItem.scss | 106 +++++ stylesheets/components/StoryViewer.scss | 113 +++++ .../components/StoryViewsNRepliesModal.scss | 152 +++++++ stylesheets/manifest.scss | 5 + ts/RemoteConfig.ts | 1 + ts/background.ts | 11 +- ts/components/App.tsx | 9 +- ts/components/Avatar.stories.tsx | 24 +- ts/components/Avatar.tsx | 10 + ts/components/CompositionArea.tsx | 4 +- ts/components/CompositionInput.tsx | 20 +- ts/components/ContextMenu.stories.tsx | 11 +- ts/components/ContextMenu.tsx | 236 +++++----- ts/components/LeftPane.tsx | 29 +- ts/components/MainHeader.stories.tsx | 6 + ts/components/MainHeader.tsx | 29 +- ts/components/MediaEditor.tsx | 14 +- ts/components/Modal.tsx | 6 +- ts/components/ModalHost.tsx | 56 ++- ts/components/Stories.stories.tsx | 124 ++++++ ts/components/Stories.tsx | 112 +++++ ts/components/StoriesPane.tsx | 124 ++++++ ts/components/StoryListItem.stories.tsx | 77 ++++ ts/components/StoryListItem.tsx | 240 +++++++++++ ts/components/StoryViewer.stories.tsx | 121 ++++++ ts/components/StoryViewer.tsx | 337 +++++++++++++++ .../StoryViewsNRepliesModal.stories.tsx | 125 ++++++ ts/components/StoryViewsNRepliesModal.tsx | 388 +++++++++++++++++ ts/components/Tabs.tsx | 61 +-- ts/components/conversation/Quote.stories.tsx | 12 + ts/components/conversation/Quote.tsx | 40 +- ts/components/emoji/EmojiButton.tsx | 6 +- ts/hooks/useTabs.tsx | 72 ++++ ts/jobs/helpers/sendNormalMessage.ts | 4 + ts/jobs/helpers/sendReaction.ts | 1 + ts/messages/helpers.ts | 8 +- ts/model-types.d.ts | 21 +- ts/models/conversations.ts | 9 + ts/models/messages.ts | 405 +++--------------- ts/services/storage.ts | 36 +- ts/services/storageRecordOps.ts | 24 +- ts/services/storyLoader.ts | 83 ++++ ts/sql/Client.ts | 6 + ts/sql/Interface.ts | 3 + ts/sql/Server.ts | 28 ++ ts/state/actions.ts | 5 +- ts/state/ducks/conversations.ts | 21 + ts/state/ducks/stories.ts | 278 ++++++++++++ ts/state/getInitialState.ts | 8 + ts/state/reducer.ts | 4 +- ts/state/selectors/items.ts | 7 + ts/state/selectors/message.ts | 10 +- ts/state/selectors/stories.ts | 98 +++++ ts/state/smart/App.tsx | 6 +- ts/state/smart/MainHeader.tsx | 2 + ts/state/smart/Stories.tsx | 69 +++ ts/state/smart/StoryViewer.tsx | 84 ++++ ts/state/types.ts | 4 +- ts/test-both/helpers/fakeAttachment.ts | 11 +- .../helpers/getDefaultConversation.ts | 8 + ts/textsecure/MessageReceiver.ts | 76 +++- ts/textsecure/SendMessage.ts | 22 + ts/textsecure/Types.d.ts | 6 +- ts/textsecure/WebAPI.ts | 3 + ts/textsecure/processDataMessage.ts | 1 + ts/types/Colors.ts | 6 +- ts/util/findStoryMessage.ts | 72 ++++ ts/util/getMessageIdForLogging.ts | 13 + ts/util/hasAttachmentDownloads.ts | 89 ++++ ts/util/leftPaneWidth.ts | 28 ++ ts/util/lint/exceptions.json | 7 + ts/util/queueAttachmentDownloads.ts | 262 +++++++++++ 84 files changed, 4382 insertions(+), 652 deletions(-) create mode 100644 images/icons/v2/add-reaction-outline-24.svg create mode 100644 images/icons/v2/messages-solid-20.svg create mode 100644 images/icons/v2/open-24.svg create mode 100644 images/icons/v2/stories-outline-24.svg create mode 100644 images/icons/v2/stories-outline-56.svg create mode 100644 stylesheets/components/MyStories.scss create mode 100644 stylesheets/components/Stories.scss create mode 100644 stylesheets/components/StoryListItem.scss create mode 100644 stylesheets/components/StoryViewer.scss create mode 100644 stylesheets/components/StoryViewsNRepliesModal.scss create mode 100644 ts/components/Stories.stories.tsx create mode 100644 ts/components/Stories.tsx create mode 100644 ts/components/StoriesPane.tsx create mode 100644 ts/components/StoryListItem.stories.tsx create mode 100644 ts/components/StoryListItem.tsx create mode 100644 ts/components/StoryViewer.stories.tsx create mode 100644 ts/components/StoryViewer.tsx create mode 100644 ts/components/StoryViewsNRepliesModal.stories.tsx create mode 100644 ts/components/StoryViewsNRepliesModal.tsx create mode 100644 ts/hooks/useTabs.tsx create mode 100644 ts/services/storyLoader.ts create mode 100644 ts/state/ducks/stories.ts create mode 100644 ts/state/selectors/stories.ts create mode 100644 ts/state/smart/Stories.tsx create mode 100644 ts/state/smart/StoryViewer.tsx create mode 100644 ts/util/findStoryMessage.ts create mode 100644 ts/util/getMessageIdForLogging.ts create mode 100644 ts/util/hasAttachmentDownloads.ts create mode 100644 ts/util/leftPaneWidth.ts create mode 100644 ts/util/queueAttachmentDownloads.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9a4711eec..642d37ea2 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1087,6 +1087,9 @@ "accept": { "message": "Accept" }, + "forward": { + "message": "Forward" + }, "done": { "message": "Done", "description": "Label for done" @@ -2323,6 +2326,10 @@ "message": "New conversation", "description": "Label for header when starting a new conversation" }, + "stories": { + "message": "Stories", + "description": "Label for header to go to stories view" + }, "contactSearchPlaceholder": { "message": "Search by name or phone number", "description": "Placeholder to use when searching for contacts in the composer" @@ -6759,6 +6766,150 @@ "message": "Crop", "description": "Performs the crop" }, + "MyStories__title": { + "message": "My Stories", + "description": "Title for the my stories list" + }, + "MyStories__story": { + "message": "Your story", + "description": "aria-label for each one of your stories" + }, + "MyStories__download": { + "message": "Download story", + "description": "aria-label for the download button" + }, + "MyStories__more": { + "message": "More options", + "description": "aria-label for the more button" + }, + "MyStories__views--singular": { + "message": "$num$ view", + "description": "Number of views your story has", + "placeholders": { + "num": { + "content": "$1", + "example": "1" + } + } + }, + "MyStories__views--plural": { + "message": "$num$ views", + "description": "Number of views your story has", + "placeholders": { + "num": { + "content": "$1", + "example": "16" + } + } + }, + "MyStories__replies--singular": { + "message": "$num$ reply", + "description": "Number of replies your story has", + "placeholders": { + "num": { + "content": "$1", + "example": "1" + } + } + }, + "MyStories__replies--plural": { + "message": "$num$ replies", + "description": "Number of replies your story has", + "placeholders": { + "num": { + "content": "$1", + "example": "3" + } + } + }, + "MyStories__delete": { + "message": "Delete this story? It will also be deleted for everyone who received it.", + "description": "Confirmation dialog description text for deleting a story" + }, + "Stories__title": { + "message": "Stories", + "description": "Title for the stories list" + }, + "Stories__mine": { + "message": "My Stories", + "description": "Label for your stories" + }, + "Stories__add": { + "message": "Add a story", + "description": "Description hint to add a story" + }, + "Stories__list-empty": { + "message": "No recent stories to show right now", + "description": "Description for when there are no stories to show" + }, + "Stories__placeholder--text": { + "message": "Click to view a story", + "description": "Placeholder label for the story view" + }, + "Stories__from-to-group": { + "message": "$name$ to $group$", + "description": "Title for someone sending a story to a group", + "placeholders": { + "name": { + "content": "$1", + "example": "Elle" + }, + "group": { + "content": "$2", + "example": "Family" + } + } + }, + "StoryViewer__reply": { + "message": "Reply", + "description": "Button label to reply to a story" + }, + "StoryViewsNRepliesModal__placeholder": { + "message": "Type a reply...", + "description": "Placeholder text for the story reply modal" + }, + "StoryViewsNRepliesModal__tab--views": { + "message": "Views", + "description": "Title for views tab" + }, + "StoryViewsNRepliesModal__tab--replies": { + "message": "Replies", + "description": "Title for replies tab" + }, + "StoryViewsNRepliesModal__react": { + "message": "React to story", + "description": "aria-label for reaction button" + }, + "StoryViewsNRepliesModal__reacted": { + "message": "Reacted to the story", + "description": "Description of someone reacting to a story" + }, + "StoryListItem__label": { + "message": "Story", + "description": "aria-label for the story list button" + }, + "StoryListItem__hide": { + "message": "Hide story", + "description": "Label for menu item to hide the story" + }, + "StoryListItem__go-to-chat": { + "message": "Go to chat", + "description": "Label for menu item to go to conversation" + }, + "StoryListItem__hide-modal--body": { + "message": "Hide story? New story updates from $name$ won’t appear at the top of the stories list anymore.", + "description": "Body for the confirmation dialog for hiding a story" + }, + "StoryListItem__hide-modal--confirm": { + "message": "Hide", + "description": "Action button for the confirmation dialog to hide a story", + "placeholders": { + "name": { + "content": "$1", + "example": "Abby" + } + } + }, "WhatsNew__modal-title": { "message": "What's New", "description": "Title for the whats new modal" diff --git a/images/icons/v2/add-reaction-outline-24.svg b/images/icons/v2/add-reaction-outline-24.svg new file mode 100644 index 000000000..75a4fe5b3 --- /dev/null +++ b/images/icons/v2/add-reaction-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/messages-solid-20.svg b/images/icons/v2/messages-solid-20.svg new file mode 100644 index 000000000..d96a991ed --- /dev/null +++ b/images/icons/v2/messages-solid-20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/open-24.svg b/images/icons/v2/open-24.svg new file mode 100644 index 000000000..bff343e2a --- /dev/null +++ b/images/icons/v2/open-24.svg @@ -0,0 +1 @@ +open-24 \ No newline at end of file diff --git a/images/icons/v2/stories-outline-24.svg b/images/icons/v2/stories-outline-24.svg new file mode 100644 index 000000000..c1d0bd062 --- /dev/null +++ b/images/icons/v2/stories-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/stories-outline-56.svg b/images/icons/v2/stories-outline-56.svg new file mode 100644 index 000000000..d50b0d009 --- /dev/null +++ b/images/icons/v2/stories-outline-56.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 811a2b42e..fe55df782 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -320,7 +320,37 @@ message TypingMessage { message StoryMessage { optional bytes profileKey = 1; optional GroupContextV2 group = 2; - optional AttachmentPointer attachment = 3; + oneof attachment { + AttachmentPointer fileAttachment = 3; + TextAttachment textAttachment = 4; + } +} + +message TextAttachment { + enum Style { + DEFAULT = 0; + REGULAR = 1; + BOLD = 2; + SERIF = 3; + SCRIPT = 4; + CONDENSED = 5; + } + + message Gradient { + optional uint32 startColor = 1; + optional uint32 endColor = 2; + optional uint32 angle = 3; // degrees + } + + optional string text = 1; + optional Style textStyle = 2; + optional uint32 textForegroundColor = 3; // integer representation of hex color + optional uint32 textBackgroundColor = 4; + optional Preview preview = 5; + oneof background { + Gradient gradient = 6; + uint32 color = 7; + } } message Verified { diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index e270b2833..0acc53fd1 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only package signalservice; @@ -78,6 +78,7 @@ message ContactRecord { optional bool archived = 11; optional bool markedUnread = 12; optional uint64 mutedUntilTimestamp = 13; + optional bool hideStory = 14; } message GroupV1Record { @@ -97,6 +98,7 @@ message GroupV2Record { optional bool markedUnread = 5; optional uint64 mutedUntilTimestamp = 6; optional bool dontNotifyForMentionsIfMuted = 7; + optional bool hideStory = 8; } message AccountRecord { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 231bd58f8..72a0cc161 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2703,13 +2703,17 @@ button.ConversationDetails__action-button { } } + &__icon-container { + display: flex; + } + &__compose-icon { -webkit-app-region: no-drag; align-items: center; background: none; border-radius: 4px; border: 2px solid transparent; - display: flex; + display: inline-flex; height: 32px; justify-content: center; padding: 2px; @@ -2769,6 +2773,74 @@ button.ConversationDetails__action-button { } } } + + &__stories-icon { + -webkit-app-region: no-drag; + align-items: center; + background: none; + border-radius: 4px; + border: 2px solid transparent; + display: inline-flex; + height: 32px; + justify-content: center; + padding: 2px; + width: 32px; + margin-right: 8px; + + @include light-theme { + &:hover, + &:focus { + background: $color-gray-15; + } + &:active { + background: $color-gray-05; + } + } + @include dark-theme { + &:hover, + &:focus { + background: $color-gray-75; + } + &:active { + background: $color-gray-65; + } + } + + @include keyboard-mode { + &:focus { + border-color: $color-ultramarine; + } + } + @include dark-keyboard-mode { + &:focus { + border-color: $color-ultramarine-light; + } + } + + &::before { + $icon: '../images/icons/v2/stories-outline-24.svg'; + width: 24px; + height: 24px; + content: ''; + + @include light-theme { + @include color-svg($icon, $color-gray-75); + &:hover, + &:active, + &:focus { + @include color-svg($icon, $color-gray-90); + } + } + @include dark-theme { + @include color-svg($icon, $color-gray-15); + &:hover, + &:active, + &:focus { + @include color-svg($icon, $color-gray-02); + } + } + } + } } // Module: Image @@ -7747,7 +7819,7 @@ button.module-image__border-overlay:focus { z-index: $z-index-popup-overlay; } -.module-modal-host__container { +.module-modal-host__overlay-container { display: flex; flex-direction: column; height: 100vh; diff --git a/stylesheets/components/Avatar.scss b/stylesheets/components/Avatar.scss index 3230e5b78..f8923630e 100644 --- a/stylesheets/components/Avatar.scss +++ b/stylesheets/components/Avatar.scss @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only .module-Avatar { @@ -138,4 +138,14 @@ } } } + + &--with-story { + border-radius: 100%; + border: 2px solid $color-white-alpha-40; + padding: 3px; + + &--unread { + border-color: $color-ultramarine-dawn; + } + } } diff --git a/stylesheets/components/MyStories.scss b/stylesheets/components/MyStories.scss new file mode 100644 index 000000000..3b8696c98 --- /dev/null +++ b/stylesheets/components/MyStories.scss @@ -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 + ); + } + } +} diff --git a/stylesheets/components/Stories.scss b/stylesheets/components/Stories.scss new file mode 100644 index 000000000..e82bd0fcc --- /dev/null +++ b/stylesheets/components/Stories.scss @@ -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 + ); + } + } +} diff --git a/stylesheets/components/StoryListItem.scss b/stylesheets/components/StoryListItem.scss new file mode 100644 index 000000000..e4aef1f83 --- /dev/null +++ b/stylesheets/components/StoryListItem.scss @@ -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); + } + } +} diff --git a/stylesheets/components/StoryViewer.scss b/stylesheets/components/StoryViewer.scss new file mode 100644 index 000000000..e8809c8b5 --- /dev/null +++ b/stylesheets/components/StoryViewer.scss @@ -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%; + } + } +} diff --git a/stylesheets/components/StoryViewsNRepliesModal.scss b/stylesheets/components/StoryViewsNRepliesModal.scss new file mode 100644 index 000000000..eff73d768 --- /dev/null +++ b/stylesheets/components/StoryViewsNRepliesModal.scss @@ -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; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index e7f1c4e47..0c3dada41 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -86,6 +86,7 @@ @import './components/MessageBody.scss'; @import './components/MessageDetail.scss'; @import './components/Modal.scss'; +@import './components/MyStories.scss'; @import './components/PermissionsPopup.scss'; @import './components/Preferences.scss'; @import './components/ProfileEditor.scss'; @@ -97,6 +98,10 @@ @import './components/SearchResultsLoadingFakeRow.scss'; @import './components/Select.scss'; @import './components/Slider.scss'; +@import './components/Stories.scss'; +@import './components/StoryListItem.scss'; +@import './components/StoryViewsNRepliesModal.scss'; +@import './components/StoryViewer.scss'; @import './components/SystemMessage.scss'; @import './components/Tabs.scss'; @import './components/TimelineDateHeader.scss'; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index cf7feefaf..910c43bb7 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -24,6 +24,7 @@ export type ConfigKeyType = | 'desktop.sendSenderKey3' | 'desktop.showUserBadges.beta' | 'desktop.showUserBadges2' + | 'desktop.stories' | 'desktop.usernames' | 'global.calling.maxGroupCallRingSize' | 'global.groupsv2.groupSizeHardLimit' diff --git a/ts/background.ts b/ts/background.ts index e9c119b65..77d661c6f 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -38,6 +38,7 @@ import { normalizeUuid } from './util/normalizeUuid'; import { filter } from './util/iterables'; import { isNotNil } from './util/isNotNil'; import { IdleDetector } from './IdleDetector'; +import { loadStories, getStoriesForRedux } from './services/storyLoader'; import { senderCertificateService } from './services/senderCertificate'; import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher'; import * as KeyboardLayout from './services/keyboardLayout'; @@ -860,6 +861,7 @@ export async function startApp(): Promise { Stickers.load(), loadRecentEmojis(), loadInitialBadgesState(), + loadStories(), window.textsecure.storage.protocol.hydrateCaches(), ]); await window.ConversationController.checkForConflicts(); @@ -890,7 +892,10 @@ export async function startApp(): Promise { function initializeRedux() { // Here we set up a full redux store with initial state for our LeftPane Root const convoCollection = window.getConversations(); - const initialState = getInitialState({ badges: initialBadgesState }); + const initialState = getInitialState({ + badges: initialBadgesState, + stories: getStoriesForRedux(), + }); const store = window.Signal.State.createStore(initialState); window.reduxStore = store; @@ -937,6 +942,7 @@ export async function startApp(): Promise { ), search: bindActionCreators(actionCreators.search, store.dispatch), stickers: bindActionCreators(actionCreators.stickers, store.dispatch), + stories: bindActionCreators(actionCreators.stories, store.dispatch), updates: bindActionCreators(actionCreators.updates, store.dispatch), user: bindActionCreators(actionCreators.user, store.dispatch), }; @@ -2063,6 +2069,7 @@ export async function startApp(): Promise { 'gv1-migration': true, senderKey: true, changeNumber: true, + stories: true, }), updateOurUsername(), ]); @@ -3268,7 +3275,7 @@ export async function startApp(): Promise { received_at_ms: data.receivedAtDate, conversationId: descriptor.id, unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, - type: 'incoming', + type: data.message.isStory ? 'story' : 'incoming', readStatus: ReadStatus.Unread, timestamp: data.timestamp, } as Partial as WhatIsThis); diff --git a/ts/components/App.tsx b/ts/components/App.tsx index b416fae31..5512f031a 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -16,15 +16,17 @@ import { useReducedMotion } from '../hooks/useReducedMotion'; type PropsType = { appView: AppViewType; + openInbox: () => void; + registerSingleDevice: (number: string, code: string) => Promise; renderCallManager: () => JSX.Element; renderGlobalModalContainer: () => JSX.Element; - openInbox: () => void; + isShowingStoriesView: boolean; + renderStories: () => JSX.Element; requestVerification: ( type: 'sms' | 'voice', number: string, token: string ) => Promise; - registerSingleDevice: (number: string, code: string) => Promise; theme: ThemeType; } & ComponentProps; @@ -36,11 +38,13 @@ export const App = ({ getPreferredBadge, i18n, isCustomizingPreferredReactions, + isShowingStoriesView, renderCallManager, renderCustomizingPreferredReactionsModal, renderGlobalModalContainer, renderSafetyNumber, openInbox, + renderStories, requestVerification, registerSingleDevice, theme, @@ -118,6 +122,7 @@ export const App = ({ > {renderGlobalModalContainer()} {renderCallManager()} + {isShowingStoriesView && renderStories()} {contents} ); diff --git a/ts/components/Avatar.stories.tsx b/ts/components/Avatar.stories.tsx index ecb38d4c2..f3dad62ff 100644 --- a/ts/components/Avatar.stories.tsx +++ b/ts/components/Avatar.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -9,7 +9,7 @@ import { boolean, select, text } from '@storybook/addon-knobs'; import { action } from '@storybook/addon-actions'; import type { Props } from './Avatar'; -import { Avatar, AvatarBlur } from './Avatar'; +import { Avatar, AvatarBlur, AvatarStoryRing } from './Avatar'; import { setupI18n } from '../util/setupI18n'; import enMessages from '../../_locales/en/messages.json'; import type { AvatarColorType } from '../types/Colors'; @@ -236,3 +236,23 @@ story.add('Blurred with "click to view"', () => { return ; }); + +story.add('Story: unread', () => ( + +)); + +story.add('Story: read', () => ( + +)); diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index dd403cc9e..75b6a9901 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -45,6 +45,11 @@ export enum AvatarSize { ONE_HUNDRED_TWELVE = 112, } +export enum AvatarStoryRing { + Unread = 'Unread', + Read = 'Read', +} + type BadgePlacementType = { bottom: number; right: number }; export type Props = { @@ -65,6 +70,7 @@ export type Props = { title: string; unblurredAvatarPath?: string; searchResult?: boolean; + storyRing?: AvatarStoryRing; onClick?: (event: MouseEvent) => unknown; onClickBadge?: (event: MouseEvent) => unknown; @@ -118,6 +124,7 @@ export const Avatar: FunctionComponent = ({ title, unblurredAvatarPath, searchResult, + storyRing, blur = getDefaultBlur({ acceptedMessageRequest, avatarPath, @@ -301,6 +308,9 @@ export const Avatar: FunctionComponent = ({ className={classNames( 'module-Avatar', hasImage ? 'module-Avatar--with-image' : 'module-Avatar--no-image', + storyRing && 'module-Avatar--with-story', + storyRing === AvatarStoryRing.Unread && + 'module-Avatar--with-story--unread', className )} style={{ diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 8519744c1..8173f74ba 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -1,4 +1,4 @@ -// Copyright 2019-2021 Signal Messenger, LLC +// Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { MutableRefObject } from 'react'; @@ -623,7 +623,7 @@ export const CompositionArea = ({ // This one is for redux... setQuotedMessage(undefined); // and this is for conversation_view. - clearQuotedMessage(); + clearQuotedMessage?.(); }} /> diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 834b24b5a..e19577ef5 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -1,4 +1,4 @@ -// Copyright 2019-2021 Signal Messenger, LLC +// Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -61,6 +61,7 @@ export type InputApi = { }; export type Props = { + children?: React.ReactNode; readonly i18n: LocalizerType; readonly disabled?: boolean; readonly getPreferredBadge: PreferredBadgeSelectorType; @@ -71,6 +72,7 @@ export type Props = { readonly draftBodyRanges?: Array; readonly moduleClassName?: string; readonly theme: ThemeType; + readonly placeholder?: string; sortedGroupMembers?: Array; onDirtyChange?(dirty: boolean): unknown; onEditorStateChange?( @@ -85,8 +87,8 @@ export type Props = { mentions: Array, timestamp: number ): unknown; - getQuotedMessage(): unknown; - clearQuotedMessage(): unknown; + getQuotedMessage?(): unknown; + clearQuotedMessage?(): unknown; }; const MAX_LENGTH = 64 * 1024; @@ -94,6 +96,7 @@ const BASE_CLASS_NAME = 'module-composition-input'; export function CompositionInput(props: Props): React.ReactElement { const { + children, i18n, disabled, large, @@ -101,6 +104,7 @@ export function CompositionInput(props: Props): React.ReactElement { moduleClassName, onPickEmoji, onSubmit, + placeholder, skinTone, draftText, draftBodyRanges, @@ -341,8 +345,8 @@ export function CompositionInput(props: Props): React.ReactElement { } } - if (getQuotedMessage()) { - clearQuotedMessage(); + if (getQuotedMessage?.()) { + clearQuotedMessage?.(); return false; } @@ -561,7 +565,7 @@ export function CompositionInput(props: Props): React.ReactElement { }, }} formats={['emoji', 'mention']} - placeholder={i18n('sendMessage')} + placeholder={placeholder || i18n('sendMessage')} readOnly={disabled} ref={element => { if (element) { @@ -635,9 +639,11 @@ export function CompositionInput(props: Props): React.ReactElement { onClick={focus} className={classNames( getClassName('__input__scroller'), - large ? getClassName('__input__scroller--large') : null + large ? getClassName('__input__scroller--large') : null, + children ? getClassName('__input--with-children') : null )} > + {children} {reactQuill} {emojiCompletionElement} {mentionCompletionElement} diff --git a/ts/components/ContextMenu.stories.tsx b/ts/components/ContextMenu.stories.tsx index 9e7097354..8df450676 100644 --- a/ts/components/ContextMenu.stories.tsx +++ b/ts/components/ContextMenu.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -19,21 +19,20 @@ const getDefaultProps = (): PropsType => ({ menuOptions: [ { label: '1', - value: 1, + onClick: action('1'), }, { label: '2', - value: 2, + onClick: action('2'), }, { label: '3', - value: 3, + onClick: action('3'), }, ], - onChange: action('onChange'), - value: 1, }); +// TODO DESKTOP-3184 story.add('Default', () => { return ; }); diff --git a/ts/components/ContextMenu.tsx b/ts/components/ContextMenu.tsx index 0cc04b5a8..784d6af9c 100644 --- a/ts/components/ContextMenu.tsx +++ b/ts/components/ContextMenu.tsx @@ -1,8 +1,10 @@ -// Copyright 2018-2021 Signal Messenger, LLC +// Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { KeyboardEvent } from 'react'; -import React, { useCallback, useEffect, useState } from 'react'; +import type { Options } from '@popperjs/core'; +import FocusTrap from 'focus-trap-react'; +import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import { usePopper } from 'react-popper'; import { noop } from 'lodash'; @@ -12,27 +14,128 @@ import type { LocalizerType } from '../types/Util'; import { themeClassName } from '../util/theme'; type OptionType = { + readonly description?: string; readonly icon?: string; readonly label: string; - readonly description?: string; - readonly value: T; + readonly onClick: (value?: T) => unknown; + readonly value?: T; +}; + +export type ContextMenuPropsType = { + readonly focusedIndex?: number; + readonly isMenuShowing: boolean; + readonly menuOptions: ReadonlyArray>; + readonly onClose: () => unknown; + readonly popperOptions?: Pick; + readonly referenceElement: HTMLElement | null; + readonly theme?: Theme; + readonly title?: string; + readonly value?: T; }; export type PropsType = { readonly buttonClassName?: string; readonly i18n: LocalizerType; - readonly menuOptions: ReadonlyArray>; - readonly onChange: (value: T) => unknown; - readonly theme?: Theme; - readonly title?: string; - readonly value: T; -}; +} & Pick< + ContextMenuPropsType, + 'menuOptions' | 'popperOptions' | 'theme' | 'title' | 'value' +>; + +export function ContextMenuPopper({ + menuOptions, + focusedIndex, + isMenuShowing, + popperOptions, + onClose, + referenceElement, + title, + value, +}: ContextMenuPropsType): JSX.Element | null { + const [popperElement, setPopperElement] = useState( + 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 ( +
+ {title &&
{title}
} + {menuOptions.map((option, index) => ( + + ))} +
+ ); +} export function ContextMenu({ buttonClassName, i18n, menuOptions, - onChange, + popperOptions, theme, title, value, @@ -42,13 +145,6 @@ export function ContextMenu({ undefined ); - // We use regular MouseEvent below, and this one uses React.MouseEvent - const handleClick = (ev: KeyboardEvent | React.MouseEvent) => { - setMenuShowing(true); - ev.stopPropagation(); - ev.preventDefault(); - }; - const handleKeyDown = (ev: KeyboardEvent) => { if (!menuShowing) { if (ev.key === 'Enter') { @@ -77,7 +173,8 @@ export function ContextMenu({ if (ev.key === 'Enter') { if (focusedIndex !== undefined) { - onChange(menuOptions[focusedIndex].value); + const focusedOption = menuOptions[focusedIndex]; + focusedOption.onClick(focusedOption.value); } setMenuShowing(false); ev.stopPropagation(); @@ -85,39 +182,15 @@ export function ContextMenu({ } }; - const handleClose = useCallback(() => { - setMenuShowing(false); - setFocusedIndex(undefined); - }, [setMenuShowing]); + // We use regular MouseEvent below, and this one uses React.MouseEvent + const handleClick = (ev: KeyboardEvent | React.MouseEvent) => { + setMenuShowing(true); + ev.stopPropagation(); + ev.preventDefault(); + }; const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState( - 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 (
@@ -132,55 +205,22 @@ export function ContextMenu({ ref={setReferenceElement} type="button" /> - {menuShowing && ( -
- {title &&
{title}
} - {menuOptions.map((option, index) => ( - - ))} -
- )} + + setMenuShowing(false)} + popperOptions={popperOptions} + referenceElement={referenceElement} + title={title} + value={value} + /> +
); } diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 419ade824..4cdc2223c 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -28,10 +28,15 @@ import { ScrollBehavior } from '../types/Util'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import { usePrevious } from '../hooks/usePrevious'; import { missingCaseError } from '../util/missingCaseError'; -import { strictAssert } from '../util/assert'; -import { isSorted } from '../util/isSorted'; import type { WidthBreakpoint } from './_util'; import { getConversationListWidthBreakpoint } from './_util'; +import { + MIN_WIDTH, + SNAP_WIDTH, + MIN_FULL_WIDTH, + MAX_WIDTH, + getWidthFromPreferredWidth, +} from '../util/leftPaneWidth'; import { ConversationList } from './ConversationList'; import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; @@ -42,15 +47,6 @@ import type { SaveAvatarToDiskActionType, } from '../types/Avatar'; -const MIN_WIDTH = 97; -const SNAP_WIDTH = 200; -const MIN_FULL_WIDTH = 280; -const MAX_WIDTH = 380; -strictAssert( - isSorted([MIN_WIDTH, SNAP_WIDTH, MIN_FULL_WIDTH, MAX_WIDTH]), - 'Expected widths to be in the right order' -); - export enum LeftPaneMode { Inbox, Search, @@ -499,13 +495,6 @@ export const LeftPane: React.FC = ({ selectedConversationId ); - let width: number; - if (requiresFullWidth || preferredWidth >= SNAP_WIDTH) { - width = Math.max(preferredWidth, MIN_FULL_WIDTH); - } else { - width = MIN_WIDTH; - } - const isScrollable = helper.isScrollable(); let rowIndexToScrollTo: undefined | number; @@ -527,6 +516,10 @@ export const LeftPane: React.FC = ({ // It also ensures that we scroll to the top when switching views. const listKey = preRowsNode ? 1 : 0; + const width = getWidthFromPreferredWidth(preferredWidth, { + requiresFullWidth, + }); + const widthBreakpoint = getConversationListWidthBreakpoint(width); // We disable this lint rule because we're trying to capture bubbled events. See [the diff --git a/ts/components/MainHeader.stories.tsx b/ts/components/MainHeader.stories.tsx index 0cfc1f542..3795928ba 100644 --- a/ts/components/MainHeader.stories.tsx +++ b/ts/components/MainHeader.stories.tsx @@ -22,6 +22,7 @@ const optionalText = (name: string, value: string | undefined) => text(name, value || '') || undefined; const createProps = (overrideProps: Partial = {}): PropsType => ({ + areStoriesEnabled: false, theme: ThemeType.light, phoneNumber: optionalText('phoneNumber', overrideProps.phoneNumber), @@ -37,6 +38,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ showArchivedConversations: action('showArchivedConversations'), startComposing: action('startComposing'), toggleProfileEditor: action('toggleProfileEditor'), + toggleStoriesView: action('toggleStoriesView'), }); story.add('Basic', () => { @@ -68,3 +70,7 @@ story.add('Update Available', () => { return ; }); + +story.add('Stories', () => ( + +)); diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index 26e883247..912684467 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -13,6 +13,7 @@ import type { AvatarColorType } from '../types/Colors'; import type { BadgeType } from '../badges/types'; export type PropsType = { + areStoriesEnabled: boolean; avatarPath?: string; badge?: BadgeType; color?: AvatarColorType; @@ -30,6 +31,7 @@ export type PropsType = { startComposing: () => void; startUpdate: () => unknown; toggleProfileEditor: () => void; + toggleStoriesView: () => unknown; }; type StateType = { @@ -111,6 +113,7 @@ export class MainHeader extends React.Component { public override render(): JSX.Element { const { + areStoriesEnabled, avatarPath, badge, color, @@ -125,6 +128,7 @@ export class MainHeader extends React.Component { theme, title, toggleProfileEditor, + toggleStoriesView, } = this.props; const { showingAvatarPopup, popperRoot } = this.state; @@ -204,13 +208,24 @@ export class MainHeader extends React.Component { ) : null} - + { + 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 && ( + onHideStory?.(sender.id), + style: 'affirmative', + text: i18n('StoryListItem__hide-modal--confirm'), + }, + ]} + i18n={i18n} + onClose={() => { + setHasConfirmHideStory(false); + }} + > + {i18n('StoryListItem__hide-modal--body', [String(firstName)])} + + )} + + ); +}; diff --git a/ts/components/StoryViewer.stories.tsx b/ts/components/StoryViewer.stories.tsx new file mode 100644 index 000000000..a3f4fbdd0 --- /dev/null +++ b/ts/components/StoryViewer.stories.tsx @@ -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: () =>
, + 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", () => ); + +story.add('Wide story', () => ( + +)); + +story.add('In a group', () => ( + +)); + +story.add('Multi story', () => { + const sender = getDefaultConversation(); + return ( + + ); +}); + +story.add('So many stories', () => { + const sender = getDefaultConversation(); + return ( + + ); +}); diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx new file mode 100644 index 000000000..4232351bc --- /dev/null +++ b/ts/components/StoryViewer.tsx @@ -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, + timestamp: number, + story: StoryViewType + ) => unknown; + onUseEmoji: (_: EmojiPickDataType) => unknown; + preferredReactionEmoji: Array; + recentEmojis?: Array; + replies?: number; + renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; + skinTone?: number; + stories: Array; + 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 ( +
+
+
+ + )} +
+
+ {hasReplyModal && ( + 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={[]} + /> + )} +
+ ); +}; diff --git a/ts/components/StoryViewsNRepliesModal.stories.tsx b/ts/components/StoryViewsNRepliesModal.stories.tsx new file mode 100644 index 000000000..a7faa3fe3 --- /dev/null +++ b/ts/components/StoryViewsNRepliesModal.stories.tsx @@ -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: () =>
, + 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', () => ( + +)); + +story.add('Views only', () => ( + +)); + +story.add('In a group', () => { + const { views, replies } = getViewsAndReplies(); + + return ( + + ); +}); diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx new file mode 100644 index 000000000..a91f224c0 --- /dev/null +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -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, + timestamp: number + ) => unknown; + onSetSkinTone: (tone: number) => unknown; + onTextTooLong: () => unknown; + onUseEmoji: (_: EmojiPickDataType) => unknown; + preferredReactionEmoji: Array; + recentEmojis?: Array; + renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; + replies: Array; + skinTone?: number; + storyPreviewAttachment?: AttachmentType; + views: Array; +}; + +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(); + 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(null); + const [popperElement, setPopperElement] = useState( + null + ); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: 'top-start', + strategy: 'fixed', + }); + + let composerElement: JSX.Element | undefined; + + if (!isMyStory) { + composerElement = ( +
+
+ {!replies.length && ( + + )} + { + setMessageBodyText(messageText); + }} + onPickEmoji={insertEmoji} + onSubmit={onReply} + onTextTooLong={onTextTooLong} + placeholder={i18n('StoryViewsNRepliesModal__placeholder')} + theme={ThemeType.dark} + > + + +
+
+ ); + } + + const repliesElement = replies.length ? ( +
+ {replies.map(reply => + reply.reactionEmoji ? ( +
+
+ +
+
+ +
+ {i18n('StoryViewsNRepliesModal__reacted')} + +
+
+ +
+ ) : ( +
+ +
+
+ +
+ + + + +
+
+ ) + )} +
+ ) : undefined; + + const viewsElement = views.length ? ( +
+ {views.map(view => ( +
+
+ + + + +
+ +
+ ))} +
+ ) : undefined; + + const tabsElement = + views.length && replies.length ? ( + + {({ selectedTab }) => ( + <> + {selectedTab === Tab.Views && viewsElement} + {selectedTab === Tab.Replies && ( + <> + {repliesElement} + {composerElement} + + )} + + )} + + ) : undefined; + + const hasOnlyViewsElement = + viewsElement && !repliesElement && !composerElement; + + return ( + + {tabsElement || ( + <> + {viewsElement} + {repliesElement} + {composerElement} + + )} + + ); +}; diff --git a/ts/components/Tabs.tsx b/ts/components/Tabs.tsx index cc5c1ae95..5e1abe1e0 100644 --- a/ts/components/Tabs.tsx +++ b/ts/components/Tabs.tsx @@ -1,24 +1,15 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { KeyboardEvent, ReactNode } from 'react'; -import React, { useState } from 'react'; -import classNames from 'classnames'; -import { assert } from '../util/assert'; -import { getClassNamesFor } from '../util/getClassNamesFor'; +import type { ReactNode } from 'react'; +import React from 'react'; -type Tab = { - id: string; - label: string; -}; +import type { TabsOptionsType } from '../hooks/useTabs'; +import { useTabs } from '../hooks/useTabs'; type PropsType = { children: (renderProps: { selectedTab: string }) => ReactNode; - initialSelectedTab?: string; - moduleClassName?: string; - onTabChange?: (selectedTab: string) => unknown; - tabs: Array; -}; +} & TabsOptionsType; export const Tabs = ({ children, @@ -27,42 +18,16 @@ export const Tabs = ({ onTabChange, tabs, }: PropsType): JSX.Element => { - assert(tabs.length, 'Tabs needs more than 1 tab present'); - - const [selectedTab, setSelectedTab] = useState( - initialSelectedTab || tabs[0].id - ); - - const getClassName = getClassNamesFor('Tabs', moduleClassName); + const { selectedTab, tabsHeaderElement } = useTabs({ + initialSelectedTab, + moduleClassName, + onTabChange, + tabs, + }); return ( <> -
- {tabs.map(({ id, label }) => ( -
{ - 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} -
- ))} -
+ {tabsHeaderElement} {children({ selectedTab })} ); diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index 8c4dd2187..a57858620 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -213,6 +213,9 @@ story.add('Image Only', () => { isVoiceMessage: false, thumbnail: { contentType: IMAGE_PNG, + height: 100, + width: 100, + path: pngUrl, objectUrl: pngUrl, }, }, @@ -228,6 +231,9 @@ story.add('Image Attachment', () => { isVoiceMessage: false, thumbnail: { contentType: IMAGE_PNG, + height: 100, + width: 100, + path: pngUrl, objectUrl: pngUrl, }, }, @@ -270,6 +276,9 @@ story.add('Video Only', () => { isVoiceMessage: false, thumbnail: { contentType: IMAGE_PNG, + height: 100, + width: 100, + path: pngUrl, objectUrl: pngUrl, }, }, @@ -288,6 +297,9 @@ story.add('Video Attachment', () => { isVoiceMessage: false, thumbnail: { contentType: IMAGE_PNG, + height: 100, + width: 100, + path: pngUrl, objectUrl: pngUrl, }, }, diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index ebea8bd1c..695f0f102 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -1,4 +1,4 @@ -// Copyright 2018-2021 Signal Messenger, LLC +// Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; @@ -10,6 +10,7 @@ import * as MIME from '../../types/MIME'; import * as GoogleChrome from '../../util/GoogleChrome'; import { MessageBody } from './MessageBody'; +import type { AttachmentType, ThumbnailType } from '../../types/Attachment'; import type { BodyRangesType, LocalizerType } from '../../types/Util'; import type { ConversationColorType, @@ -40,19 +41,10 @@ type State = { imageBroken: boolean; }; -export type QuotedAttachmentType = { - contentType: MIME.MIMEType; - fileName?: string; - /** Not included in protobuf */ - isVoiceMessage: boolean; - thumbnail?: Attachment; -}; - -type Attachment = { - contentType: MIME.MIMEType; - /** Not included in protobuf, and is loaded asynchronously */ - objectUrl?: string; -}; +export type QuotedAttachmentType = Pick< + AttachmentType, + 'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail' +>; function validateQuote(quote: Props): boolean { if (quote.text) { @@ -75,12 +67,12 @@ function getAttachment( : undefined; } -function getObjectUrl(thumbnail: Attachment | undefined): string | undefined { - if (thumbnail && thumbnail.objectUrl) { - return thumbnail.objectUrl; +function getUrl(thumbnail?: ThumbnailType): string | undefined { + if (!thumbnail) { + return; } - return undefined; + return thumbnail.objectUrl || thumbnail.url; } function getTypeLabel({ @@ -92,7 +84,7 @@ function getTypeLabel({ i18n: LocalizerType; isViewOnce?: boolean; contentType: MIME.MIMEType; - isVoiceMessage: boolean; + isVoiceMessage?: boolean; }): string | undefined { if (GoogleChrome.isVideoTypeSupported(contentType)) { if (isViewOnce) { @@ -249,20 +241,20 @@ export class Quote extends React.Component { } const { contentType, thumbnail } = attachment; - const objectUrl = getObjectUrl(thumbnail); + const url = getUrl(thumbnail); if (isViewOnce) { return this.renderIcon('view-once'); } if (GoogleChrome.isVideoTypeSupported(contentType)) { - return objectUrl && !imageBroken - ? this.renderImage(objectUrl, 'play') + return url && !imageBroken + ? this.renderImage(url, 'play') : this.renderIcon('movie'); } if (GoogleChrome.isImageTypeSupported(contentType)) { - return objectUrl && !imageBroken - ? this.renderImage(objectUrl) + return url && !imageBroken + ? this.renderImage(url) : this.renderIcon('image'); } if (MIME.isAudio(contentType)) { diff --git a/ts/components/emoji/EmojiButton.tsx b/ts/components/emoji/EmojiButton.tsx index 4e8b7dd01..d4508147d 100644 --- a/ts/components/emoji/EmojiButton.tsx +++ b/ts/components/emoji/EmojiButton.tsx @@ -1,4 +1,4 @@ -// Copyright 2019-2021 Signal Messenger, LLC +// Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -12,6 +12,7 @@ import { EmojiPicker } from './EmojiPicker'; import type { LocalizerType } from '../../types/Util'; export type OwnProps = { + readonly className?: string; readonly closeOnPick?: boolean; readonly emoji?: string; readonly i18n: LocalizerType; @@ -26,6 +27,7 @@ export type Props = OwnProps & export const EmojiButton = React.memo( ({ + className, closeOnPick, emoji, i18n, @@ -117,7 +119,7 @@ export const EmojiButton = React.memo( type="button" ref={ref} onClick={handleClickButton} - className={classNames({ + className={classNames(className, { 'module-emoji-button__button': true, 'module-emoji-button__button--active': open, 'module-emoji-button__button--has-emoji': Boolean(emoji), diff --git a/ts/hooks/useTabs.tsx b/ts/hooks/useTabs.tsx new file mode 100644 index 000000000..4fec18ea0 --- /dev/null +++ b/ts/hooks/useTabs.tsx @@ -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; +}; + +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( + initialSelectedTab || tabs[0].id + ); + + const tabsHeaderElement = ( +
+ {tabs.map(({ id, label }) => ( +
{ + 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} +
+ ))} +
+ ); + + return { + selectedTab, + tabsHeaderElement, + }; +} diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index f336a1b47..763a2775e 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -141,6 +141,7 @@ export async function sendNormalMessage( preview, quote, sticker, + storyContextTimestamp, } = await getMessageSendData({ log, message }); let messageSendPromise: Promise; @@ -253,6 +254,7 @@ export async function sendNormalMessage( groupId: undefined, profileKey, options: sendOptions, + storyContextTimestamp, }); } @@ -400,6 +402,7 @@ async function getMessageSendData({ preview: Array; quote: WhatIsThis; sticker: WhatIsThis; + storyContextTimestamp?: number; }> { const { loadAttachmentData, @@ -454,6 +457,7 @@ async function getMessageSendData({ preview, quote, sticker, + storyContextTimestamp: message.get('sent_at'), }; } diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index 3be12c174..a0d837db8 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -220,6 +220,7 @@ export async function sendReaction( groupId: undefined, profileKey, options: sendOptions, + storyContextTimestamp: message.get('sent_at'), }); } else { log.info('sending group reaction message'); diff --git a/ts/messages/helpers.ts b/ts/messages/helpers.ts index 037283bad..0001adad5 100644 --- a/ts/messages/helpers.ts +++ b/ts/messages/helpers.ts @@ -9,7 +9,7 @@ import type { QuotedMessageType, } from '../model-types.d'; import type { UUIDStringType } from '../types/UUID'; -import { isIncoming, isOutgoing } from '../state/selectors/message'; +import { isIncoming, isOutgoing, isStory } from '../state/selectors/message'; export function isQuoteAMatch( message: MessageAttributesType | null | undefined, @@ -57,7 +57,7 @@ export function getContact( } export function getSource(message: MessageAttributesType): string | undefined { - if (isIncoming(message)) { + if (isIncoming(message) || isStory(message)) { return message.source; } if (!isOutgoing(message)) { @@ -72,7 +72,7 @@ export function getSourceDevice( ): string | number | undefined { const { sourceDevice } = message; - if (isIncoming(message)) { + if (isIncoming(message) || isStory(message)) { return sourceDevice; } if (!isOutgoing(message)) { @@ -87,7 +87,7 @@ export function getSourceDevice( export function getSourceUuid( message: MessageAttributesType ): UUIDStringType | undefined { - if (isIncoming(message)) { + if (isIncoming(message) || isStory(message)) { return message.sourceUuid; } if (!isOutgoing(message)) { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 4f63d40dc..326818caf 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as Backbone from 'backbone'; @@ -69,6 +69,8 @@ export type GroupMigrationType = { invitedMembers: Array; }; +export type PreviewMessageType = Array; + export type QuotedMessageType = { attachments: Array; // `author` is an old attribute that holds the author's E164. We shouldn't use it for @@ -83,6 +85,13 @@ export type QuotedMessageType = { messageId: string; }; +export type StickerMessageType = { + packId: string; + stickerId: number; + packKey: string; + data?: AttachmentType; +}; + export type RetryOptions = Readonly<{ type: 'session-reset'; uuid: string; @@ -164,13 +173,8 @@ export type MessageAttributesType = { | 'verified-change'; body?: string; attachments?: Array; - preview?: Array; - sticker?: { - packId: string; - stickerId: number; - packKey: string; - data?: AttachmentType; - }; + preview?: PreviewMessageType; + sticker?: StickerMessageType; sent_at: number; unidentifiedDeliveries?: Array; contact?: Array; @@ -242,6 +246,7 @@ export type ConversationAttributesType = { draftAttachments?: Array; draftBodyRanges?: Array; draftTimestamp?: number | null; + hideStory?: boolean; inbox_position: number; isPinned: boolean; lastMessageDeletedForEveryone: boolean; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 89e642a07..43c69a0bd 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1810,6 +1810,7 @@ export class ConversationModel extends window.Backbone groupVersion, groupId: this.get('groupId'), groupLink: this.getGroupLink(), + hideStory: Boolean(this.get('hideStory')), inboxPosition, isArchived: this.get('isArchived')!, isBlocked: this.isBlocked(), @@ -3790,10 +3791,12 @@ export class ConversationModel extends window.Backbone { dontClearDraft, sendHQImages, + storyId, timestamp, }: { dontClearDraft?: boolean; sendHQImages?: boolean; + storyId?: string; timestamp?: number; } = {} ): Promise { @@ -3872,6 +3875,7 @@ export class ConversationModel extends window.Backbone updatedAt: now, }) ), + storyId, }); if (isDirectConversation(this.attributes)) { @@ -4963,6 +4967,11 @@ export class ConversationModel extends window.Backbone } } + toggleHideStories(): void { + this.set({ hideStory: !this.get('hideStory') }); + this.captureChange('hideStory'); + } + setMuteExpiration( muteExpiresAt = 0, { viaStorageServiceSync = false } = {} diff --git a/ts/models/messages.ts b/ts/models/messages.ts index f6df26b85..7521e437b 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -43,11 +43,6 @@ import * as expirationTimer from '../util/expirationTimer'; import type { ReactionType } from '../types/Reactions'; import { UUID, UUIDKind } from '../types/UUID'; import * as reactionUtil from '../reactions/util'; -import { - copyStickerToAttachments, - savePackMetadata, - getStickerPackStatus, -} from '../types/Stickers'; import * as Stickers from '../types/Stickers'; import * as Errors from '../types/errors'; import * as EmbeddedContact from '../types/EmbeddedContact'; @@ -99,6 +94,7 @@ import { isKeyChange, isMessageHistoryUnsynced, isOutgoing, + isStory, isProfileChange, isTapToView, isUniversalTimerNotification, @@ -124,7 +120,6 @@ import { ReactionSource } from '../reactions/ReactionSource'; import { ReadSyncs } from '../messageModifiers/ReadSyncs'; import { ViewSyncs } from '../messageModifiers/ViewSyncs'; import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs'; -import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads'; import * as LinkPreview from '../types/LinkPreview'; import { SignalService as Proto } from '../protobuf'; import { @@ -141,13 +136,18 @@ import { getContact, getContactId, getSource, - getSourceDevice, getSourceUuid, isCustomError, isQuoteAMatch, } from '../messages/helpers'; import type { ReplacementValuesType } from '../types/I18N'; import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue'; +import { getMessageIdForLogging } from '../util/getMessageIdForLogging'; +import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads'; +import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; +import { findStoryMessage } from '../util/findStoryMessage'; +import { isConversationAccepted } from '../util/isConversationAccepted'; +import { getStoryDataFromMessageAttributes } from '../services/storyLoader'; import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; /* eslint-disable camelcase */ @@ -165,7 +165,7 @@ window.Whisper = window.Whisper || {}; const { Message: TypedMessage } = window.Signal.Types; const { upgradeMessageSchema } = window.Signal.Migrations; const { getTextWithMentions, GoogleChrome } = window.Signal.Util; -const { addStickerPackReference, getMessageBySender } = window.Signal.Data; +const { getMessageBySender } = window.Signal.Data; export class MessageModel extends window.Backbone.Model { static getLongMessageAttachment: ( @@ -232,6 +232,33 @@ export class MessageModel extends window.Backbone.Model { // Note: The clone is important for triggering a re-run of selectors messageChanged(this.id, conversationId, { ...this.attributes }); } + + const { addStory } = window.reduxActions.stories; + + if (isStory(this.attributes)) { + const ourConversationId = + window.ConversationController.getOurConversationIdOrThrow(); + const storyData = getStoryDataFromMessageAttributes( + this.attributes, + ourConversationId + ); + + if (!storyData) { + return; + } + + // TODO DESKTOP-3179 + // Only add stories to redux if we've downloaded them. This should work + // because once we download a story we'll receive another change event + // which kicks off this function again. + if (Attachment.hasNotDownloaded(storyData.attachment)) { + return; + } + + // This is fine to call multiple times since the addStory action only + // adds new stories. + addStory(storyData); + } } getSenderIdentifier(): string { @@ -740,12 +767,7 @@ export class MessageModel extends window.Backbone.Model { // General idForLogging(): string { - const account = - getSourceUuid(this.attributes) || getSource(this.attributes); - const device = getSourceDevice(this.attributes); - const timestamp = this.get('sent_at'); - - return `${account}.${device} ${timestamp}`; + return getMessageIdForLogging(this.attributes); } override defaults(): Partial { @@ -1636,332 +1658,18 @@ export class MessageModel extends window.Backbone.Model { return getLastChallengeError(this.attributes); } - // NOTE: If you're modifying this function then you'll likely also need - // to modify queueAttachmentDownloads since it contains the logic below hasAttachmentDownloads(): boolean { - const attachments = this.get('attachments') || []; - - const [longMessageAttachments, normalAttachments] = _.partition( - attachments, - attachment => MIME.isLongMessage(attachment.contentType) - ); - - if (longMessageAttachments.length > 0) { - return true; - } - - const hasNormalAttachments = normalAttachments.some(attachment => { - if (!attachment) { - return false; - } - // We've already downloaded this! - if (attachment.path) { - return false; - } - return true; - }); - if (hasNormalAttachments) { - return true; - } - - const previews = this.get('preview') || []; - const hasPreviews = previews.some(item => { - if (!item.image) { - return false; - } - // We've already downloaded this! - if (item.image.path) { - return false; - } - return true; - }); - if (hasPreviews) { - return true; - } - - const contacts = this.get('contact') || []; - const hasContacts = contacts.some(item => { - if (!item.avatar || !item.avatar.avatar) { - return false; - } - if (item.avatar.avatar.path) { - return false; - } - return true; - }); - if (hasContacts) { - return true; - } - - const quote = this.get('quote'); - const quoteAttachments = - quote && quote.attachments ? quote.attachments : []; - const hasQuoteAttachments = quoteAttachments.some(item => { - if (!item.thumbnail) { - return false; - } - // We've already downloaded this! - if (item.thumbnail.path) { - return false; - } - return true; - }); - if (hasQuoteAttachments) { - return true; - } - - const sticker = this.get('sticker'); - if (sticker) { - return !sticker.data || (sticker.data && !sticker.data.path); - } - - return false; + return hasAttachmentDownloads(this.attributes); } - // Receive logic - // NOTE: If you're changing any logic in this function that deals with the - // count then you'll also have to modify the above function - // hasAttachmentDownloads async queueAttachmentDownloads(): Promise { - const attachmentsToQueue = this.get('attachments') || []; - const messageId = this.id; - let count = 0; - let bodyPending; - - log.info( - `Queueing ${ - attachmentsToQueue.length - } attachment downloads for message ${this.idForLogging()}` - ); - - const [longMessageAttachments, normalAttachments] = _.partition( - attachmentsToQueue, - attachment => MIME.isLongMessage(attachment.contentType) - ); - - if (longMessageAttachments.length > 1) { - log.error( - `Received more than one long message attachment in message ${this.idForLogging()}` - ); + const value = await queueAttachmentDownloads(this.attributes); + if (!value) { + return false; } - log.info( - `Queueing ${ - longMessageAttachments.length - } long message attachment downloads for message ${this.idForLogging()}` - ); - - if (longMessageAttachments.length > 0) { - count += 1; - bodyPending = true; - await AttachmentDownloads.addJob(longMessageAttachments[0], { - messageId, - type: 'long-message', - index: 0, - }); - } - - log.info( - `Queueing ${ - normalAttachments.length - } normal attachment downloads for message ${this.idForLogging()}` - ); - const attachments = await Promise.all( - normalAttachments.map((attachment, index) => { - if (!attachment) { - return attachment; - } - // We've already downloaded this! - if (attachment.path) { - log.info( - `Normal attachment already downloaded for message ${this.idForLogging()}` - ); - return attachment; - } - - count += 1; - - return AttachmentDownloads.addJob(attachment, { - messageId, - type: 'attachment', - index, - }); - }) - ); - - const previewsToQueue = this.get('preview') || []; - log.info( - `Queueing ${ - previewsToQueue.length - } preview attachment downloads for message ${this.idForLogging()}` - ); - const preview = await Promise.all( - previewsToQueue.map(async (item, index) => { - if (!item.image) { - return item; - } - // We've already downloaded this! - if (item.image.path) { - log.info( - `Preview attachment already downloaded for message ${this.idForLogging()}` - ); - return item; - } - - count += 1; - return { - ...item, - image: await AttachmentDownloads.addJob(item.image, { - messageId, - type: 'preview', - index, - }), - }; - }) - ); - - const contactsToQueue = this.get('contact') || []; - log.info( - `Queueing ${ - contactsToQueue.length - } contact attachment downloads for message ${this.idForLogging()}` - ); - const contact = await Promise.all( - contactsToQueue.map(async (item, index) => { - if (!item.avatar || !item.avatar.avatar) { - return item; - } - // We've already downloaded this! - if (item.avatar.avatar.path) { - log.info( - `Contact attachment already downloaded for message ${this.idForLogging()}` - ); - return item; - } - - count += 1; - return { - ...item, - avatar: { - ...item.avatar, - avatar: await AttachmentDownloads.addJob(item.avatar.avatar, { - messageId, - type: 'contact', - index, - }), - }, - }; - }) - ); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - let quote = this.get('quote')!; - const quoteAttachmentsToQueue = - quote && quote.attachments ? quote.attachments : []; - log.info( - `Queueing ${ - quoteAttachmentsToQueue.length - } quote attachment downloads for message ${this.idForLogging()}` - ); - if (quoteAttachmentsToQueue.length > 0) { - quote = { - ...quote, - attachments: await Promise.all( - (quote.attachments || []).map(async (item, index) => { - if (!item.thumbnail) { - return item; - } - // We've already downloaded this! - if (item.thumbnail.path) { - log.info( - `Quote attachment already downloaded for message ${this.idForLogging()}` - ); - return item; - } - - count += 1; - return { - ...item, - thumbnail: await AttachmentDownloads.addJob(item.thumbnail, { - messageId, - type: 'quote', - index, - }), - }; - }) - ), - }; - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - let sticker = this.get('sticker')!; - if (sticker && sticker.data && sticker.data.path) { - log.info( - `Sticker attachment already downloaded for message ${this.idForLogging()}` - ); - } else if (sticker) { - log.info(`Queueing sticker download for message ${this.idForLogging()}`); - count += 1; - const { packId, stickerId, packKey } = sticker; - - const status = getStickerPackStatus(packId); - let data: AttachmentType | undefined; - - if (status && (status === 'downloaded' || status === 'installed')) { - try { - data = await copyStickerToAttachments(packId, stickerId); - } catch (error) { - log.error( - `Problem copying sticker (${packId}, ${stickerId}) to attachments:`, - error && error.stack ? error.stack : error - ); - } - } - if (!data && sticker.data) { - data = await AttachmentDownloads.addJob(sticker.data, { - messageId, - type: 'sticker', - index: 0, - }); - } - if (!status) { - // Save the packId/packKey for future download/install - savePackMetadata(packId, packKey, { messageId }); - } else { - await addStickerPackReference(messageId, packId); - } - - if (!data) { - throw new Error( - 'queueAttachmentDownloads: Failed to fetch sticker data' - ); - } - - sticker = { - ...sticker, - packId, - data, - }; - } - - log.info( - `Queued ${count} total attachment downloads for message ${this.idForLogging()}` - ); - - if (count > 0) { - this.set({ - bodyPending, - attachments, - preview, - contact, - quote, - sticker, - }); - - return true; - } - - return false; + this.set(value); + return true; } markAttachmentAsCorrupted(attachment: AttachmentType): void { @@ -2207,6 +1915,18 @@ export class MessageModel extends window.Backbone.Model { `Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` ); + if ( + type === 'story' && + !isConversationAccepted(conversation.attributes) + ) { + log.info( + 'handleDataMessage: dropping story from !whitelisted', + this.getSenderIdentifier() + ); + confirm(); + return; + } + // First, check for duplicates. If we find one, stop processing here. const inMemoryMessage = window.MessageController.findBySender( this.getSenderIdentifier() @@ -2471,12 +2191,15 @@ export class MessageModel extends window.Backbone.Model { }); } + const [quote, storyQuote] = await Promise.all([ + this.copyFromQuotedMessage(initialMessage.quote, conversation.id), + findStoryMessage(conversation.id, initialMessage.storyContext), + ]); + const withQuoteReference = { ...initialMessage, - quote: await this.copyFromQuotedMessage( - initialMessage.quote, - conversation.id - ), + quote, + storyId: storyQuote?.id, }; const dataMessage = await upgradeMessageSchema(withQuoteReference); @@ -2521,6 +2244,7 @@ export class MessageModel extends window.Backbone.Model { quote: dataMessage.quote, schemaVersion: dataMessage.schemaVersion, sticker: dataMessage.sticker, + storyId: dataMessage.storyId, }); const isSupported = !isUnsupportedMessage(message.attributes); @@ -2807,8 +2531,8 @@ export class MessageModel extends window.Backbone.Model { conversation.incrementMessageCount(); window.Signal.Data.updateConversation(conversation.attributes); - // Only queue attachments for downloads if this is an outgoing message - // or we've accepted the conversation + // Only queue attachments for downloads if this is a story or + // outgoing message or we've accepted the conversation const reduxState = window.reduxStore.getState(); const attachments = this.get('attachments') || []; const shouldHoldOffDownload = @@ -2818,6 +2542,7 @@ export class MessageModel extends window.Backbone.Model { this.hasAttachmentDownloads() && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion (this.getConversation()!.getAccepted() || + isStory(message.attributes) || isOutgoing(message.attributes)) && !shouldHoldOffDownload ) { diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 74b48e32e..73bc5379a 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -129,10 +129,7 @@ function generateStorageID(): Uint8Array { } type GeneratedManifestType = { - conversationsToUpdate: Array<{ - conversation: ConversationModel; - storageID: string | undefined; - }>; + postUploadUpdateFunctions: Array<() => unknown>; deleteKeys: Array; newItems: Set; storageManifest: Proto.IStorageManifest; @@ -152,7 +149,7 @@ async function generateManifest( const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type; - const conversationsToUpdate = []; + const postUploadUpdateFunctions: Array<() => unknown> = []; const insertKeys: Array = []; const deleteKeys: Array = []; const manifestRecordKeys: Set = new Set(); @@ -275,9 +272,13 @@ async function generateManifest( ); } - conversationsToUpdate.push({ - conversation, - storageID, + postUploadUpdateFunctions.push(() => { + conversation.set({ + needsStorageServiceSync: false, + storageVersion: version, + storageID, + }); + updateConversation(conversation.attributes); }); } @@ -510,7 +511,7 @@ async function generateManifest( storageManifest.value = encryptedManifest; return { - conversationsToUpdate, + postUploadUpdateFunctions, deleteKeys, newItems, storageManifest, @@ -520,7 +521,7 @@ async function generateManifest( async function uploadManifest( version: number, { - conversationsToUpdate, + postUploadUpdateFunctions, deleteKeys, newItems, storageManifest, @@ -556,18 +557,11 @@ async function uploadManifest( log.info( `storageService.upload(${version}): upload complete, updating ` + - `conversations=${conversationsToUpdate.length}` + `items=${postUploadUpdateFunctions.length}` ); // update conversations with the new storageID - conversationsToUpdate.forEach(({ conversation, storageID }) => { - conversation.set({ - needsStorageServiceSync: false, - storageVersion: version, - storageID, - }); - updateConversation(conversation.attributes); - }); + postUploadUpdateFunctions.forEach(fn => fn()); } catch (err) { log.error( `storageService.upload(${version}): failed!`, @@ -655,11 +649,11 @@ async function createNewManifest() { const version = window.storage.get('manifestVersion', 0); - const { conversationsToUpdate, newItems, storageManifest } = + const { postUploadUpdateFunctions, newItems, storageManifest } = await generateManifest(version, undefined, true); await uploadManifest(version, { - conversationsToUpdate, + postUploadUpdateFunctions, // we have created a new manifest, there should be no keys to delete deleteKeys: [], newItems, diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index cd53f3ae3..3f139ac9b 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -154,15 +154,18 @@ export async function toContactRecord( contactRecord.mutedUntilTimestamp = getSafeLongFromTimestamp( conversation.get('muteExpiresAt') ); + if (conversation.get('hideStory') !== undefined) { + contactRecord.hideStory = Boolean(conversation.get('hideStory')); + } applyUnknownFields(contactRecord, conversation); return contactRecord; } -export async function toAccountRecord( +export function toAccountRecord( conversation: ConversationModel -): Promise { +): Proto.AccountRecord { const accountRecord = new Proto.AccountRecord(); if (conversation.get('profileKey')) { @@ -319,9 +322,9 @@ export async function toAccountRecord( return accountRecord; } -export async function toGroupV1Record( +export function toGroupV1Record( conversation: ConversationModel -): Promise { +): Proto.GroupV1Record { const groupV1Record = new Proto.GroupV1Record(); groupV1Record.id = Bytes.fromBinary(String(conversation.get('groupId'))); @@ -338,9 +341,9 @@ export async function toGroupV1Record( return groupV1Record; } -export async function toGroupV2Record( +export function toGroupV2Record( conversation: ConversationModel -): Promise { +): Proto.GroupV2Record { const groupV2Record = new Proto.GroupV2Record(); const masterKey = conversation.get('masterKey'); @@ -357,6 +360,7 @@ export async function toGroupV2Record( groupV2Record.dontNotifyForMentionsIfMuted = Boolean( conversation.get('dontNotifyForMentionsIfMuted') ); + groupV2Record.hideStory = Boolean(conversation.get('hideStory')); applyUnknownFields(groupV2Record, conversation); @@ -592,7 +596,7 @@ export async function mergeGroupV1Record( addUnknownFields(groupV1Record, conversation, details); const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges( - await toGroupV1Record(conversation), + toGroupV1Record(conversation), groupV1Record, conversation ); @@ -683,6 +687,7 @@ export async function mergeGroupV2Record( const oldStorageVersion = conversation.get('storageVersion'); conversation.set({ + hideStory: Boolean(groupV2Record.hideStory), isArchived: Boolean(groupV2Record.archived), markedUnread: Boolean(groupV2Record.markedUnread), dontNotifyForMentionsIfMuted: Boolean( @@ -706,7 +711,7 @@ export async function mergeGroupV2Record( addUnknownFields(groupV2Record, conversation, details); const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges( - await toGroupV2Record(conversation), + toGroupV2Record(conversation), groupV2Record, conversation ); @@ -852,6 +857,7 @@ export async function mergeContactRecord( const oldStorageVersion = conversation.get('storageVersion'); conversation.set({ + hideStory: Boolean(contactRecord.hideStory), isArchived: Boolean(contactRecord.archived), markedUnread: Boolean(contactRecord.markedUnread), storageID, @@ -1142,7 +1148,7 @@ export async function mergeAccountRecord( } const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges( - await toAccountRecord(conversation), + toAccountRecord(conversation), accountRecord, conversation ); diff --git a/ts/services/storyLoader.ts b/ts/services/storyLoader.ts new file mode 100644 index 000000000..f78effbae --- /dev/null +++ b/ts/services/storyLoader.ts @@ -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 | undefined; + +export async function loadStories(): Promise { + 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 { + 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; +} diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index ba90d5e0f..dfdb9820d 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -285,6 +285,7 @@ const dataInterface: ClientInterface = { _deleteAllStoryDistributions, createNewStoryDistribution, getAllStoryDistributionsWithMembers, + getStoryDistributionWithMembers, modifyStoryDistribution, modifyStoryDistributionMembers, deleteStoryDistribution, @@ -1583,6 +1584,11 @@ async function getAllStoryDistributionsWithMembers(): Promise< > { return channels.getAllStoryDistributionsWithMembers(); } +async function getStoryDistributionWithMembers( + id: string +): Promise { + return channels.getStoryDistributionWithMembers(id); +} async function modifyStoryDistribution( distribution: StoryDistributionType ): Promise { diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 6d849d263..9e52ad2da 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -536,6 +536,9 @@ export type DataInterface = { getAllStoryDistributionsWithMembers(): Promise< Array >; + getStoryDistributionWithMembers( + id: string + ): Promise; modifyStoryDistribution(distribution: StoryDistributionType): Promise; modifyStoryDistributionMembers( id: string, diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 2df0d4fdd..fab24c65a 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -281,6 +281,7 @@ const dataInterface: ServerInterface = { _deleteAllStoryDistributions, createNewStoryDistribution, getAllStoryDistributionsWithMembers, + getStoryDistributionWithMembers, modifyStoryDistribution, modifyStoryDistributionMembers, deleteStoryDistribution, @@ -3965,6 +3966,33 @@ async function getAllStoryDistributionsWithMembers(): Promise< members: (byListId[list.id] || []).map(member => member.uuid), })); } +async function getStoryDistributionWithMembers( + id: string +): Promise { + const db = getInstance(); + const storyDistribution = prepare( + db, + 'SELECT * FROM storyDistributions WHERE id = $id;' + ).get({ + id, + }); + + if (!storyDistribution) { + return undefined; + } + + const members = prepare( + db, + 'SELECT * FROM storyDistributionMembers WHERE listId = $id;' + ).all({ + id, + }); + + return { + ...storyDistribution, + members: members.map(({ uuid }) => uuid), + }; +} async function modifyStoryDistribution( distribution: StoryDistributionType ): Promise { diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 31b806d7e..1fa296cff 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -1,4 +1,4 @@ -// Copyright 2019-2021 Signal Messenger, LLC +// Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { actions as accounts } from './ducks/accounts'; @@ -19,6 +19,7 @@ import { actions as network } from './ducks/network'; import { actions as safetyNumber } from './ducks/safetyNumber'; import { actions as search } from './ducks/search'; import { actions as stickers } from './ducks/stickers'; +import { actions as stories } from './ducks/stories'; import { actions as updates } from './ducks/updates'; import { actions as user } from './ducks/user'; import type { ReduxActions } from './types'; @@ -42,6 +43,7 @@ export const actionCreators: ReduxActions = { safetyNumber, search, stickers, + stories, updates, user, }; @@ -65,6 +67,7 @@ export const mapDispatchToProps = { ...safetyNumber, ...search, ...stickers, + ...stories, ...updates, ...user, }; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 35f233634..5496f6ad7 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -78,6 +78,7 @@ import { showToast } from '../../util/showToast'; import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername'; import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchUsername'; import { isValidUsername } from '../../types/Username'; +import { useBoundActions } from '../../hooks/useBoundActions'; import type { NoopActionType } from './noop'; import { conversationJobQueue } from '../../jobs/conversationJobQueue'; @@ -130,6 +131,7 @@ export type ConversationType = { customColor?: CustomColorType; customColorId?: string; discoveredUnregisteredAt?: number; + hideStory?: boolean; isArchived?: boolean; isBlocked?: boolean; isGroupV1AndDisabled?: boolean; @@ -851,10 +853,14 @@ export const actions = { toggleAdmin, toggleConversationInChooseMembers, toggleComposeEditingAvatar, + toggleHideStories, updateConversationModelSharedGroups, verifyConversationsStoppingSend, }; +export const useConversationsActions = (): typeof actions => + useBoundActions(actions); + function filterAvatarData( avatars: ReadonlyArray, data: AvatarDataType @@ -1947,6 +1953,21 @@ function openConversationExternal( }; } +function toggleHideStories( + conversationId: string +): ThunkAction { + return dispatch => { + const conversationModel = window.ConversationController.get(conversationId); + if (conversationModel) { + conversationModel.toggleHideStories(); + } + dispatch({ + type: 'NOOP', + payload: null, + }); + }; +} + function removeMemberFromGroup( conversationId: string, contactId: string diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts new file mode 100644 index 000000000..9161ead1a --- /dev/null +++ b/ts/state/ducks/stories.ts @@ -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; +}; + +// 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 { + 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 = [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 { + 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, + 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 { + return { + isShowingStoriesView: false, + stories: [], + ...overrideState, + }; +} + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): 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; +} diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 9d4c06bd1..80e565419 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -16,19 +16,23 @@ import { getEmptyState as network } from './ducks/network'; import { getEmptyState as preferredReactions } from './ducks/preferredReactions'; import { getEmptyState as safetyNumber } from './ducks/safetyNumber'; import { getEmptyState as search } from './ducks/search'; +import { getEmptyState as getStoriesEmptyState } from './ducks/stories'; import { getEmptyState as updates } from './ducks/updates'; import { getEmptyState as user } from './ducks/user'; import type { StateType } from './reducer'; import type { BadgesStateType } from './ducks/badges'; +import type { StoryDataType } from './ducks/stories'; import { getInitialState as stickers } from '../types/Stickers'; import { getEmojiReducerState as emojis } from '../util/loadRecentEmojis'; export function getInitialState({ badges, + stories, }: { badges: BadgesStateType; + stories: Array; }): StateType { const items = window.storage.getItemsState(); @@ -87,6 +91,10 @@ export function getInitialState({ safetyNumber: safetyNumber(), search: search(), stickers: stickers(), + stories: { + ...getStoriesEmptyState(), + stories, + }, updates: updates(), user: { ...user(), diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 58edeb315..082eb84ca 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -1,4 +1,4 @@ -// Copyright 2019-2021 Signal Messenger, LLC +// Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { combineReducers } from 'redux'; @@ -22,6 +22,7 @@ import { reducer as preferredReactions } from './ducks/preferredReactions'; import { reducer as safetyNumber } from './ducks/safetyNumber'; import { reducer as search } from './ducks/search'; import { reducer as stickers } from './ducks/stickers'; +import { reducer as stories } from './ducks/stories'; import { reducer as updates } from './ducks/updates'; import { reducer as user } from './ducks/user'; @@ -45,6 +46,7 @@ export const reducer = combineReducers({ safetyNumber, search, stickers, + stories, updates, user, }); diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index b57ac77e1..1da1acd4a 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -58,6 +58,13 @@ export const getUsernamesEnabled = createSelector( isRemoteConfigFlagEnabled(remoteConfig, 'desktop.usernames') ); +export const getStoriesEnabled = createSelector( + getRemoteConfig, + (remoteConfig: ConfigMapType): boolean => + isRemoteConfigFlagEnabled(remoteConfig, 'desktop.internalUser') || + isRemoteConfigFlagEnabled(remoteConfig, 'desktop.stories') +); + export const getDefaultConversationColor = createSelector( getItems, ( diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 25176f5d7..e8c9ec93b 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -129,6 +129,12 @@ export function isOutgoing( return message.type === 'outgoing'; } +export function isStory( + message: Pick +): boolean { + return message.type === 'story'; +} + export function hasErrors( message: Pick ): 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; } diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts new file mode 100644 index 000000000..abcf583f3 --- /dev/null +++ b/ts/state/selectors/stories.ts @@ -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 + ): { + hiddenStories: Array; + stories: Array; + } => { + const storiesById = new Map(); + const hiddenStoriesById = new Map(); + + 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; + 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()), + }; + } +); diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index 019e6060f..ce335f1c0 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -9,9 +9,11 @@ import { SmartCallManager } from './CallManager'; import { SmartCustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal'; import { SmartGlobalModalContainer } from './GlobalModalContainer'; import { SmartSafetyNumberViewer } from './SafetyNumberViewer'; +import { SmartStories } from './Stories'; import type { StateType } from '../reducer'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { getIntl, getTheme } from '../selectors/user'; +import { shouldShowStoriesView } from '../selectors/stories'; import { getConversationsStoppingSend } from '../selectors/conversations'; import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions'; import { mapDispatchToProps } from '../actions'; @@ -32,6 +34,8 @@ const mapStateToProps = (state: StateType) => { renderSafetyNumber: (props: SafetyNumberProps) => ( ), + isShowingStoriesView: shouldShowStoriesView(state), + renderStories: () => , requestVerification: ( type: 'sms' | 'voice', number: string, diff --git a/ts/state/smart/MainHeader.tsx b/ts/state/smart/MainHeader.tsx index 1e4aab908..2172ab3ac 100644 --- a/ts/state/smart/MainHeader.tsx +++ b/ts/state/smart/MainHeader.tsx @@ -17,11 +17,13 @@ import { getUserUuid, } from '../selectors/user'; import { getMe } from '../selectors/conversations'; +import { getStoriesEnabled } from '../selectors/items'; const mapStateToProps = (state: StateType) => { const me = getMe(state); return { + areStoriesEnabled: getStoriesEnabled(state), hasPendingUpdate: Boolean(state.updates.didSnooze), regionCode: getRegionCode(state), ourConversationId: getUserConversationId(state), diff --git a/ts/state/smart/Stories.tsx b/ts/state/smart/Stories.tsx new file mode 100644 index 000000000..3d3087f58 --- /dev/null +++ b/ts/state/smart/Stories.tsx @@ -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 ( + + ); +} + +export function SmartStories(): JSX.Element | null { + const storiesActions = useStoriesActions(); + const { openConversationInternal, toggleHideStories } = + useConversationsActions(); + + const i18n = useSelector(getIntl); + + const isShowingStoriesView = useSelector( + (state: StateType) => state.stories.isShowingStoriesView + ); + + const preferredWidthFromStorage = useSelector( + getPreferredLeftPaneWidth + ); + + const { hiddenStories, stories } = useSelector(getStories); + + if (!isShowingStoriesView) { + return null; + } + + return ( + + ); +} diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx new file mode 100644 index 000000000..5ea125272 --- /dev/null +++ b/ts/state/smart/StoryViewer.tsx @@ -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; +}; + +export function SmartStoryViewer({ + conversationId, + onClose, + onNextUserStories, + onPrevUserStories, + stories, +}: PropsType): JSX.Element | null { + const storiesActions = useStoriesActions(); + const { onSetSkinTone } = useItemsActions(); + const { onUseEmoji } = useEmojisActions(); + + const i18n = useSelector(getIntl); + const getPreferredBadge = useSelector(getPreferredBadgeSelector); + const preferredReactionEmoji = useSelector>( + getPreferredReactionEmoji + ); + + const recentEmojis = useRecentEmojis(); + const skinTone = useSelector(getEmojiSkinTone); + + return ( + { + 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} + /> + ); +} diff --git a/ts/state/types.ts b/ts/state/types.ts index 141ef5298..f12fb3979 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { actions as accounts } from './ducks/accounts'; @@ -19,6 +19,7 @@ import type { actions as network } from './ducks/network'; import type { actions as safetyNumber } from './ducks/safetyNumber'; import type { actions as search } from './ducks/search'; import type { actions as stickers } from './ducks/stickers'; +import type { actions as stories } from './ducks/stories'; import type { actions as updates } from './ducks/updates'; import type { actions as user } from './ducks/user'; @@ -41,6 +42,7 @@ export type ReduxActions = { safetyNumber: typeof safetyNumber; search: typeof search; stickers: typeof stickers; + stories: typeof stories; updates: typeof updates; user: typeof user; }; diff --git a/ts/test-both/helpers/fakeAttachment.ts b/ts/test-both/helpers/fakeAttachment.ts index d4c2a22e0..1c068692d 100644 --- a/ts/test-both/helpers/fakeAttachment.ts +++ b/ts/test-both/helpers/fakeAttachment.ts @@ -1,9 +1,10 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { AttachmentType, AttachmentDraftType, + ThumbnailType, } from '../../types/Attachment'; import { IMAGE_JPEG } from '../../types/MIME'; @@ -17,6 +18,14 @@ export const fakeAttachment = ( ...overrides, }); +export const fakeThumbnail = (url: string): ThumbnailType => ({ + contentType: IMAGE_JPEG, + height: 100, + path: url, + url, + width: 100, +}); + export const fakeDraftAttachment = ( overrides: Partial = {} ): AttachmentDraftType => ({ diff --git a/ts/test-both/helpers/getDefaultConversation.ts b/ts/test-both/helpers/getDefaultConversation.ts index f4b3f32c6..b6f7dde5a 100644 --- a/ts/test-both/helpers/getDefaultConversation.ts +++ b/ts/test-both/helpers/getDefaultConversation.ts @@ -317,6 +317,13 @@ const LAST_NAMES = [ export const getFirstName = (): string => sample(FIRST_NAMES) || 'Test'; export const getLastName = (): string => sample(LAST_NAMES) || 'Test'; +export const getAvatarPath = (): string => + sample([ + '/fixtures/kitten-1-64-64.jpg', + '/fixtures/kitten-2-64-64.jpg', + '/fixtures/kitten-3-64-64.jpg', + ]) || ''; + export function getDefaultConversation( overrideProps: Partial = {} ): ConversationType { @@ -325,6 +332,7 @@ export function getDefaultConversation( return { acceptedMessageRequest: true, + avatarPath: getAvatarPath(), badges: [], e164: '+1300555000', color: getRandomColor(), diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 4c109dc22..79cb0b81d 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -72,6 +72,7 @@ import type { Storage } from './Storage'; import { WarnOnlyError } from './Errors'; import * as Bytes from '../Bytes'; import type { + ProcessedAttachment, ProcessedDataMessage, ProcessedSyncMessage, ProcessedSent, @@ -107,6 +108,7 @@ import { GroupSyncEvent, } from './messageReceiverEvents'; import * as log from '../logging/log'; +import * as durations from '../util/durations'; import { areArraysMatchingSets } from '../util/areArraysMatchingSets'; const GROUPV1_ID_LENGTH = 16; @@ -1787,6 +1789,66 @@ export default class MessageReceiver return this.dispatchAndWait(ev); } + private async handleStoryMessage( + envelope: UnsealedEnvelope, + msg: Proto.IStoryMessage + ): Promise { + const logId = this.getEnvelopeId(envelope); + log.info('MessageReceiver.handleStoryMessage', logId); + + const attachments: Array = []; + + if (msg.fileAttachment) { + const attachment = processAttachment(msg.fileAttachment); + attachments.push(attachment); + } + + if (msg.textAttachment) { + log.error( + 'MessageReceiver.handleStoryMessage: Got a textAttachment, cannot handle it', + logId + ); + return; + } + + const expireTimer = envelope.timestamp + durations.DAY - Date.now(); + + if (expireTimer <= 0) { + log.info( + 'MessageReceiver.handleStoryMessage: story already expired', + logId + ); + this.removeFromCache(envelope); + return; + } + + const ev = new MessageEvent( + { + source: envelope.source, + sourceUuid: envelope.sourceUuid, + sourceDevice: envelope.sourceDevice, + timestamp: envelope.timestamp, + serverGuid: envelope.serverGuid, + serverTimestamp: envelope.serverTimestamp, + unidentifiedDeliveryReceived: Boolean( + envelope.unidentifiedDeliveryReceived + ), + message: { + attachments, + expireTimer, + flags: 0, + isStory: true, + isViewOnce: false, + timestamp: envelope.timestamp, + }, + receivedAtCounter: envelope.receivedAtCounter, + receivedAtDate: envelope.receivedAtDate, + }, + this.removeFromCache.bind(this, envelope) + ); + return this.dispatchAndWait(ev); + } + private async handleDataMessage( envelope: UnsealedEnvelope, msg: Proto.IDataMessage @@ -1794,14 +1856,6 @@ export default class MessageReceiver const logId = this.getEnvelopeId(envelope); log.info('MessageReceiver.handleDataMessage', logId); - if (msg.storyContext) { - log.info( - `MessageReceiver.handleDataMessage/${logId}: Dropping incoming dataMessage with storyContext field` - ); - this.removeFromCache(envelope); - return undefined; - } - let p: Promise = Promise.resolve(); // eslint-disable-next-line no-bitwise const destination = envelope.sourceUuid; @@ -1993,11 +2047,7 @@ export default class MessageReceiver return; } if (content.storyMessage) { - const logId = this.getEnvelopeId(envelope); - log.info( - `innerHandleContentMessage/${logId}: Dropping incoming message with storyMessage field` - ); - this.removeFromCache(envelope); + await this.handleStoryMessage(envelope, content.storyMessage); return; } diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index be9fd7af2..02a0a3f7f 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -191,6 +191,7 @@ export type MessageOptionsType = { timestamp: number; mentions?: BodyRangesType; groupCallUpdate?: GroupCallUpdateType; + storyContextTimestamp?: number; }; export type GroupSendOptionsType = { attachments?: Array; @@ -208,6 +209,7 @@ export type GroupSendOptionsType = { timestamp: number; mentions?: BodyRangesType; groupCallUpdate?: GroupCallUpdateType; + storyContextTimestamp?: number; }; class Message { @@ -252,6 +254,8 @@ class Message { groupCallUpdate?: GroupCallUpdateType; + storyContextTimestamp?: number; + constructor(options: MessageOptionsType) { this.attachments = options.attachments || []; this.body = options.body; @@ -270,6 +274,7 @@ class Message { this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp; this.mentions = options.mentions; this.groupCallUpdate = options.groupCallUpdate; + this.storyContextTimestamp = options.storyContextTimestamp; if (!(this.recipients instanceof Array)) { throw new Error('Invalid recipient list'); @@ -470,6 +475,18 @@ class Message { proto.groupCallUpdate = groupCallUpdate; } + if (this.storyContextTimestamp) { + const { StoryContext } = Proto.DataMessage; + + const storyContext = new StoryContext(); + storyContext.authorUuid = String( + window.textsecure.storage.user.getCheckedUuid() + ); + storyContext.sentTimestamp = this.storyContextTimestamp; + + proto.storyContext = storyContext; + } + this.dataMessage = proto; return proto; } @@ -779,6 +796,7 @@ export default class MessageSender { quote, reaction, sticker, + storyContextTimestamp, timestamp, } = options; @@ -833,6 +851,7 @@ export default class MessageSender { reaction, recipients, sticker, + storyContextTimestamp, timestamp, }; } @@ -1024,6 +1043,7 @@ export default class MessageSender { groupId, profileKey, options, + storyContextTimestamp, }: Readonly<{ identifier: string; messageText: string | undefined; @@ -1038,6 +1058,7 @@ export default class MessageSender { contentHint: number; groupId: string | undefined; profileKey?: Uint8Array; + storyContextTimestamp?: number; options?: SendOptionsType; }>): Promise { return this.sendMessage({ @@ -1053,6 +1074,7 @@ export default class MessageSender { deletedForEveryoneTimestamp, expireTimer, profileKey, + storyContextTimestamp, }, contentHint, groupId, diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 1d06e9f40..ff60479c3 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { SignalService as Proto } from '../protobuf'; @@ -183,6 +183,8 @@ export type ProcessedBodyRange = Proto.DataMessage.IBodyRange; export type ProcessedGroupCallUpdate = Proto.DataMessage.IGroupCallUpdate; +export type ProcessedStoryContext = Proto.DataMessage.IStoryContext; + export type ProcessedDataMessage = { body?: string; attachments: ReadonlyArray; @@ -197,11 +199,13 @@ export type ProcessedDataMessage = { preview?: ReadonlyArray; sticker?: ProcessedSticker; requiredProtocolVersion?: number; + isStory?: boolean; isViewOnce: boolean; reaction?: ProcessedReaction; delete?: ProcessedDelete; bodyRanges?: ReadonlyArray; groupCallUpdate?: ProcessedGroupCallUpdate; + storyContext?: ProcessedStoryContext; }; export type ProcessedUnidentifiedDeliveryStatus = Omit< diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 369a8f072..e32a3e06b 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -661,6 +661,7 @@ export type CapabilitiesType = { 'gv1-migration': boolean; senderKey: boolean; changeNumber: boolean; + stories: boolean; }; export type CapabilitiesUploadType = { announcementGroup: true; @@ -668,6 +669,7 @@ export type CapabilitiesUploadType = { 'gv1-migration': true; senderKey: true; changeNumber: true; + stories: true; }; type StickerPackManifestType = Uint8Array; @@ -1726,6 +1728,7 @@ export function initialize({ 'gv1-migration': true, senderKey: true, changeNumber: true, + stories: true, }; const { accessKey } = options; diff --git a/ts/textsecure/processDataMessage.ts b/ts/textsecure/processDataMessage.ts index ddcb8a98a..c572875e0 100644 --- a/ts/textsecure/processDataMessage.ts +++ b/ts/textsecure/processDataMessage.ts @@ -280,6 +280,7 @@ export async function processDataMessage( delete: processDelete(message.delete), bodyRanges: message.bodyRanges ?? [], groupCallUpdate: dropNull(message.groupCallUpdate), + storyContext: dropNull(message.storyContext), }; const isEndSession = Boolean(result.flags & FLAGS.END_SESSION); diff --git a/ts/types/Colors.ts b/ts/types/Colors.ts index 841b5abee..0f41a5ea4 100644 --- a/ts/types/Colors.ts +++ b/ts/types/Colors.ts @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only export const AvatarColorMap = new Map([ @@ -184,3 +184,7 @@ export type CustomColorsItemType = { readonly colors: Record; readonly version: number; }; + +export function getAvatarColor(color?: AvatarColorType): AvatarColorType { + return color || AvatarColors[0]; +} diff --git a/ts/util/findStoryMessage.ts b/ts/util/findStoryMessage.ts new file mode 100644 index 000000000..92b32200e --- /dev/null +++ b/ts/util/findStoryMessage.ts @@ -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 { + 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 + ); +} diff --git a/ts/util/getMessageIdForLogging.ts b/ts/util/getMessageIdForLogging.ts new file mode 100644 index 000000000..2b1abc67a --- /dev/null +++ b/ts/util/getMessageIdForLogging.ts @@ -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}`; +} diff --git a/ts/util/hasAttachmentDownloads.ts b/ts/util/hasAttachmentDownloads.ts new file mode 100644 index 000000000..0a05abd56 --- /dev/null +++ b/ts/util/hasAttachmentDownloads.ts @@ -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; +} diff --git a/ts/util/leftPaneWidth.ts b/ts/util/leftPaneWidth.ts new file mode 100644 index 000000000..587fce0c0 --- /dev/null +++ b/ts/util/leftPaneWidth.ts @@ -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; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 0a830c343..54f1e055c 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -7639,6 +7639,13 @@ "reasonCategory": "usageTrusted", "updated": "2021-11-30T10:15:33.662Z" }, + { + "rule": "React-useRef", + "path": "ts/components/StoryViewsNRepliesModal.tsx", + "line": " const inputApiRef = React.useRef();", + "reasonCategory": "usageTrusted", + "updated": "2022-02-15T17:57:06.507Z" + }, { "rule": "React-useRef", "path": "ts/components/Tooltip.tsx", diff --git a/ts/util/queueAttachmentDownloads.ts b/ts/util/queueAttachmentDownloads.ts new file mode 100644 index 000000000..f99f2d0e6 --- /dev/null +++ b/ts/util/queueAttachmentDownloads.ts @@ -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; + preview: PreviewMessageType; + contact: Array; + 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 { + 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, + }; +}