diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index bd118790b..8b203c733 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -2960,6 +2960,29 @@ Signal Desktop makes use of the following open source projects. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## react-textarea-autosize + + The MIT License (MIT) + + Copyright (c) 2013 Andrey Popp + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ## react-virtualized The MIT License (MIT) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 21a073772..0480b9ea5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7229,6 +7229,58 @@ "message": "Error displaying image", "description": "aria-label for image errors" }, + "StoryCreator__text-bg": { + "message": "Toggle text background color", + "description": "Button label" + }, + "StoryCreator__story-bg": { + "message": "Change story background color", + "description": "Button label" + }, + "StoryCreator__next": { + "message": "Next", + "description": "Button label text to advance to next step of story creation" + }, + "StoryCreator__add-link": { + "message": "Add link", + "description": "Button label to apply the link preview to story" + }, + "StoryCreator__input-placeholder": { + "message": "Add text", + "description": "Placeholder to add text" + }, + "StoryCreator__text--regular": { + "message": "Regular", + "description": "Label for font" + }, + "StoryCreator__text--bold": { + "message": "Bold", + "description": "Label for font" + }, + "StoryCreator__text--serif": { + "message": "Serif", + "description": "Label for font" + }, + "StoryCreator__text--script": { + "message": "Script", + "description": "Label for font" + }, + "StoryCreator__text--condensed": { + "message": "Condensed", + "description": "Label for font" + }, + "StoryCreator__link-preview-placeholder": { + "message": "Type or paste a URL", + "description": "Placeholder for the URL input for link previews" + }, + "StoryCreator__link-preview-empty": { + "message": "Add a link for viewers of your story", + "description": "Empty state for the link preview" + }, + "TextAttachment__placeholder": { + "message": "Add text", + "description": "Placeholder for the add text input" + }, "TextAttachment__preview__link": { "message": "Visit link", "description": "Title for the link preview tooltip" diff --git a/images/icons/v2/font-bold.svg b/images/icons/v2/font-bold.svg new file mode 100644 index 000000000..4c836b0a1 --- /dev/null +++ b/images/icons/v2/font-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/font-condensed.svg b/images/icons/v2/font-condensed.svg new file mode 100644 index 000000000..cb5118d8e --- /dev/null +++ b/images/icons/v2/font-condensed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/font-regular.svg b/images/icons/v2/font-regular.svg new file mode 100644 index 000000000..2ecbefb63 --- /dev/null +++ b/images/icons/v2/font-regular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/font-script.svg b/images/icons/v2/font-script.svg new file mode 100644 index 000000000..c015bd6e2 --- /dev/null +++ b/images/icons/v2/font-script.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/font-serif.svg b/images/icons/v2/font-serif.svg new file mode 100644 index 000000000..5d2c9d962 --- /dev/null +++ b/images/icons/v2/font-serif.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/text-effect-off-24.svg b/images/icons/v2/text-effect-off-24.svg new file mode 100644 index 000000000..3584a9344 --- /dev/null +++ b/images/icons/v2/text-effect-off-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/text-effect-on-24.svg b/images/icons/v2/text-effect-on-24.svg new file mode 100644 index 000000000..267c323ea --- /dev/null +++ b/images/icons/v2/text-effect-on-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index e06306c4f..cf2103b51 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,7 @@ "react-redux": "7.2.8", "react-router-dom": "5.0.1", "react-sortable-hoc": "2.0.0", + "react-textarea-autosize": "8.3.4", "react-virtualized": "9.22.3", "read-last-lines": "1.8.0", "redux": "4.1.2", diff --git a/patches/react-textarea-autosize+8.3.4.patch b/patches/react-textarea-autosize+8.3.4.patch new file mode 100644 index 000000000..c0e83b6d5 --- /dev/null +++ b/patches/react-textarea-autosize+8.3.4.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.dev.js b/node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.dev.js +index ce25001..36bcd17 100644 +--- a/node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.dev.js ++++ b/node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.dev.js +@@ -110,7 +110,7 @@ var pick = function pick(props, obj) { + var SIZING_STYLE = ['borderBottomWidth', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth', 'boxSizing', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'letterSpacing', 'lineHeight', 'paddingBottom', 'paddingLeft', 'paddingRight', 'paddingTop', // non-standard + 'tabSize', 'textIndent', // non-standard + 'textRendering', 'textTransform', 'width', 'wordBreak']; +-var isIE = typeof document !== 'undefined' ? !!document.documentElement.currentStyle : false; ++var isIE = false; + + var getSizingData = function getSizingData(node) { + var style = window.getComputedStyle(node); +diff --git a/node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.prod.js b/node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.prod.js +index d4e39a2..f26641e 100644 +--- a/node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.prod.js ++++ b/node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.prod.js +@@ -110,7 +110,7 @@ var pick = function pick(props, obj) { + var SIZING_STYLE = ['borderBottomWidth', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth', 'boxSizing', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'letterSpacing', 'lineHeight', 'paddingBottom', 'paddingLeft', 'paddingRight', 'paddingTop', // non-standard + 'tabSize', 'textIndent', // non-standard + 'textRendering', 'textTransform', 'width', 'wordBreak']; +-var isIE = typeof document !== 'undefined' ? !!document.documentElement.currentStyle : false; ++var isIE = false; + + var getSizingData = function getSizingData(node) { + var style = window.getComputedStyle(node); diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index f0edf2617..4fbe387b2 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3334,133 +3334,6 @@ button.module-image__border-overlay:focus { } } -// Module: Staged Link Preview - -.module-staged-link-preview { - position: relative; - display: flex; - flex-direction: row; - align-items: stretch; - - min-height: 65px; -} - -.module-staged-link-preview--is-loading { - align-items: center; -} -.module-staged-link-preview__loading { - text-align: center; - flex-grow: 1; - flex-shrink: 1; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-staged-link-preview__icon-container { - margin-right: 8px; -} -.module-staged-link-preview__content { - display: flex; - flex-direction: column; - margin-right: 20px; -} -.module-staged-link-preview__title { - @include font-body-1-bold; - - @include light-theme { - color: $color-gray-90; - } - @include dark-theme { - color: $color-gray-05; - } - - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; -} -.module-staged-link-preview__description { - @include font-body-1; - - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; -} -.module-staged-link-preview__footer { - @include font-body-2; - - display: flex; - flex-flow: row wrap; - align-items: center; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } - - > *:not(:first-child) { - display: flex; - - &:before { - content: '•'; - font-size: 50%; - margin-left: 0.2rem; - margin-right: 0.2rem; - } - } -} -.module-staged-link-preview__location { - @include font-body-2; - - text-transform: lowercase; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} -.module-staged-link-preview__close-button { - @include button-reset; - - position: absolute; - top: 0px; - right: 0px; - - height: 16px; - width: 16px; - - @include light-theme { - @include color-svg('../images/icons/v2/x-24.svg', $color-gray-60); - } - @include keyboard-mode { - &:focus { - @include color-svg('../images/icons/v2/x-24.svg', $color-ultramarine); - } - } - - @include dark-theme { - @include color-svg('../images/icons/v2/x-24.svg', $color-gray-25); - } - @include dark-keyboard-mode { - &:focus { - @include color-svg( - '../images/icons/v2/x-24.svg', - $color-ultramarine-light - ); - } - } -} - // Module: Spinner .module-spinner__container { diff --git a/stylesheets/components/HueSlider.scss b/stylesheets/components/HueSlider.scss new file mode 100644 index 000000000..ec097800c --- /dev/null +++ b/stylesheets/components/HueSlider.scss @@ -0,0 +1,30 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.HueSlider.Slider { + background-image: linear-gradient( + 90deg, + hsl(0, 0%, 0%), + hsl(0, 100%, 50%), + hsl(45, 100%, 50%), + hsl(90, 100%, 50%), + hsl(135, 100%, 50%), + hsl(180, 100%, 50%), + hsl(225, 100%, 50%), + hsl(270, 100%, 50%), + hsl(315, 100%, 50%), + hsl(0, 0%, 100%) + ); + border-radius: 4px; + height: 8px; + margin-left: 7px; + width: 280px; + + &__handle.Slider__handle { + border: 7px solid $color-white; + margin-top: -7px; + margin-left: -11px; + height: 22px; + width: 22px; + } +} diff --git a/stylesheets/components/MediaEditor.scss b/stylesheets/components/MediaEditor.scss index 10e910ab4..6289611f5 100644 --- a/stylesheets/components/MediaEditor.scss +++ b/stylesheets/components/MediaEditor.scss @@ -221,35 +221,6 @@ } } - &__hue-slider.Slider { - background-image: linear-gradient( - 90deg, - hsl(0, 0%, 100%), - hsl(0, 0%, 0%), - hsl(0, 100%, 50%), - hsl(45, 100%, 50%), - hsl(90, 100%, 50%), - hsl(135, 100%, 50%), - hsl(180, 100%, 50%), - hsl(225, 100%, 50%), - hsl(270, 100%, 50%), - hsl(315, 100%, 50%), - hsl(360, 100%, 50%) - ); - border-radius: 4px; - height: 8px; - margin-left: 7px; - width: 280px; - } - - &__hue-slider__handle.Slider__handle { - border: 7px solid $color-white; - margin-top: -7px; - margin-left: -11px; - height: 22px; - width: 22px; - } - &__icon { &--draw-pen { @include color-svg('../images/icons/v2/pen-20.svg', $color-white); diff --git a/stylesheets/components/StagedLinkPreview.scss b/stylesheets/components/StagedLinkPreview.scss new file mode 100644 index 000000000..5524e414e --- /dev/null +++ b/stylesheets/components/StagedLinkPreview.scss @@ -0,0 +1,146 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-staged-link-preview { + position: relative; + display: flex; + flex-direction: row; + align-items: stretch; + + min-height: 65px; + + &__no-image { + align-items: center; + background-color: $color-white; + border-radius: 14px; + display: flex; + flex-direction: row; + height: 74px; + justify-content: center; + margin-right: 32px; + width: 74px; + + &::after { + @include color-svg('../images/icons/v2/link-24.svg', $color-black); + content: ''; + height: 44px; + width: 44px; + } + } +} + +.module-staged-link-preview--is-loading { + align-items: center; +} +.module-staged-link-preview__loading { + text-align: center; + flex-grow: 1; + flex-shrink: 1; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } +} + +.module-staged-link-preview__icon-container { + margin-right: 8px; +} +.module-staged-link-preview__content { + display: flex; + flex-direction: column; + margin-right: 20px; +} +.module-staged-link-preview__title { + @include font-body-1-bold; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } + + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} +.module-staged-link-preview__description { + @include font-body-1; + + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} +.module-staged-link-preview__footer { + @include font-body-2; + + display: flex; + flex-flow: row wrap; + align-items: center; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + + > *:not(:first-child) { + display: flex; + + &:before { + content: '•'; + font-size: 50%; + margin-left: 0.2rem; + margin-right: 0.2rem; + } + } +} +.module-staged-link-preview__location { + @include font-body-2; + + text-transform: lowercase; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } +} +.module-staged-link-preview__close-button { + @include button-reset; + + position: absolute; + top: 0px; + right: 0px; + + height: 16px; + width: 16px; + + @include light-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-60); + } + @include keyboard-mode { + &:focus { + @include color-svg('../images/icons/v2/x-24.svg', $color-ultramarine); + } + } + + @include dark-theme { + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-25); + } + @include dark-keyboard-mode { + &:focus { + @include color-svg( + '../images/icons/v2/x-24.svg', + $color-ultramarine-light + ); + } + } +} diff --git a/stylesheets/components/StoryCreator.scss b/stylesheets/components/StoryCreator.scss new file mode 100644 index 000000000..ad6b7f530 --- /dev/null +++ b/stylesheets/components/StoryCreator.scss @@ -0,0 +1,319 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.StoryCreator { + $tools-height: 44px; + + @mixin svg($icon) { + @include color-svg('../images/icons/v2/#{$icon}', $color-white); + } + + background: $color-gray-95; + display: flex; + flex-direction: column; + height: 100vh; + left: 0; + position: absolute; + top: 0; + user-select: none; + width: 100vw; + z-index: $z-index-popup-overlay; + + &__container { + display: flex; + flex: 1; + justify-content: center; + overflow: hidden; + padding-bottom: 0; + padding: 22px 60px; + position: relative; + } + + &__input { + background: transparent; + border: none; + color: transparent; + position: absolute; + text-align: center; + top: 50%; + user-select: none; + + &:focus { + outline: none; + } + } + + &__controls { + align-items: center; + display: flex; + flex-grow: 1; + flex-wrap: wrap; + justify-content: center; + max-width: 596px; + } + + &__control { + @include button-reset; + align-items: center; + border-radius: 32px; + display: inline-flex; + height: 32px; + justify-content: center; + margin: 0 15px; + opacity: 1; + width: 32px; + + &::after { + content: ' '; + height: 24px; + width: 24px; + } + + &--link::after { + @include color-svg('../images/icons/v2/link-24.svg', $color-white); + } + + &--text::after { + @include color-svg('../images/icons/v2/text-24.svg', $color-white); + } + + &--bg { + @include rounded-corners; + border: 1.5px solid $color-white; + display: block; + height: 24px; + padding: 2.5px; + width: 24px; + &::after { + display: none; + } + + &--selected { + border-width: 4px; + padding: 0; + } + } + + &--selected { + background-color: $color-white; + + &::after { + background-color: $color-black; + } + } + + &:hover { + background-color: $color-gray-80; + + &::after { + background-color: $color-white; + } + } + } + + &__toolbar { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + padding: 22px; + width: 100%; + + &--buttons { + align-items: center; + display: flex; + justify-content: center; + width: 100%; + } + + &--space { + height: $tools-height; + margin-bottom: 22px; + } + } + + &__tools { + align-items: center; + background-color: $color-gray-90; + border-radius: 10px; + color: $color-white; + display: flex; + height: $tools-height; + justify-content: center; + margin-bottom: 22px; + padding: 14px 12px; + + &__tool { + margin-right: 14px; + } + + &__button { + @mixin icon($icon) { + @include svg($icon); + opacity: 1; + height: 20px; + width: 20px; + border-radius: 0; + + &::after { + display: none; + } + } + + @include button-reset; + margin: 0 8px; + padding: 8px; + + &--bg { + @include icon('text-effect-on-24.svg'); + } + &--bg-inverse { + @include icon('text-effect-on-24.svg'); + } + &--bg-none { + @include icon('text-effect-off-24.svg'); + } + &--font-regular { + @include icon('font-regular.svg'); + } + &--font-bold { + @include icon('font-bold.svg'); + } + &--font-serif { + @include icon('font-serif.svg'); + } + &--font-script { + @include icon('font-script.svg'); + } + &--font-condensed { + @include icon('font-condensed.svg'); + } + } + } + + &__icon { + &--font-regular { + @include svg('font-regular.svg'); + } + &--font-bold { + @include svg('font-bold.svg'); + } + &--font-serif { + @include svg('font-serif.svg'); + } + &--font-script { + @include svg('font-script.svg'); + } + &--font-condensed { + @include svg('font-condensed.svg'); + } + } + + &__bg { + @include button-reset; + @include rounded-corners; + + border: 2px solid transparent; + height: 24px; + margin: 4px; + width: 24px; + + &--selected { + border: 2px solid $color-white; + } + } + + &__popper { + background: $color-gray-80; + border-radius: 10px; + margin-bottom: 18px; + padding: 8px; + width: 144px; + + &__arrow { + border-left: 14px solid transparent; + border-right: 14px solid transparent; + border-top: 14px solid $color-gray-80; + bottom: -14px; + height: 0; + left: 50%; + position: absolute; + transform: translateX(-50%); + width: 0; + } + } + + &__link-preview-input-popper { + display: flex; + flex-direction: column; + height: 256px; + padding: 16px; + width: 360px; + } + + &__link-preview-input__container { + margin-top: 0; + } + + &__link-preview-container { + align-items: center; + display: flex; + flex-direction: column; + flex: 1; + justify-content: center; + } + + &__link-preview-button { + margin-top: 18px; + margin-bottom: 8px; + } + + &__link-preview { + background: $color-black-alpha-40; + border-radius: 16px; + display: flex; + padding: 14px; + width: 100%; + + &__image { + border-radius: 8px; + height: 76px; + width: 76px; + } + + &__meta { + display: flex; + flex-direction: column; + justify-content: center; + margin-left: 14px; + } + + &__title { + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + @include font-body-1-bold; + color: $color-white; + display: -webkit-box; + overflow: hidden; + user-select: none; + } + + &__location { + @include font-subtitle; + color: $color-white-alpha-60; + } + } + + &__link-preview-empty { + align-items: center; + color: $color-gray-45; + display: flex; + flex-direction: column; + + &__icon { + @include color-svg('../images/icons/v2/link-24.svg', $color-gray-45); + height: 24px; + width: 24px; + } + } +} diff --git a/stylesheets/components/TextAttachment.scss b/stylesheets/components/TextAttachment.scss index 39502d211..855541168 100644 --- a/stylesheets/components/TextAttachment.scss +++ b/stylesheets/components/TextAttachment.scss @@ -35,6 +35,26 @@ -webkit-line-clamp: 13; display: -webkit-box; overflow: hidden; + user-select: none; + } + + &__textarea { + background: inherit; + border: none; + padding: 0; + resize: none; + text-align: center; + width: 100%; + + &:disabled { + color: inherit; + cursor: inherit; + } + + &:focus { + border: none; + outline: none; + } } } @@ -50,56 +70,40 @@ margin-right: 72px; padding: 34px; - &--large { + .TextAttachment__preview-container--large & { height: 192px; } - &__image { - align-items: center; - background-color: $color-white; - border-radius: 14px; - display: flex; - flex-direction: row; - height: 74px; - justify-content: center; - margin-right: 32px; - width: 74px; - - .TextAttachment__preview--large & { + &__no-image { + .TextAttachment__preview-container--large & { height: 144px; width: 144px; } + } - &::after { - @include color-svg('../images/icons/v2/link-24.svg', $color-black); - content: ''; - height: 44px; - width: 44px; + &__content { + align-items: flex-start; + display: flex; + flex-direction: column; + justify-content: flex-start; + margin: 0; + max-width: 422px; + + .TextAttachment__preview-container--large & { + max-width: 352px; } } &__title { - align-items: flex-start; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; color: $color-gray-05; - display: flex; - flex-direction: column; - justify-content: flex-start; - max-width: 422px; - - .TextAttachment__preview--large & { - max-width: 352px; - } - - &__container { - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - display: -webkit-box; - font: bold 30px Inter; - overflow: hidden; - } + display: -webkit-box; + font: bold 30px Inter; + overflow: hidden; } - &__url { + &__location { color: $color-white; font: bold 30px Inter; max-width: 422px; @@ -107,7 +111,7 @@ text-overflow: ellipsis; white-space: nowrap; - .TextAttachment__preview--large & { + .TextAttachment__preview-container--large & { color: $color-white-alpha-60; font: 24px Inter; max-width: 352px; diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 9c1d18de2..0ca0bffc4 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -71,6 +71,7 @@ @import './components/GroupDescription.scss'; @import './components/GroupDialog.scss'; @import './components/GroupInput.scss'; +@import './components/HueSlider.scss'; @import './components/Inbox.scss'; @import './components/IncomingCallBar.scss'; @import './components/Input.scss'; @@ -103,7 +104,9 @@ @import './components/SearchResultsLoadingFakeRow.scss'; @import './components/Select.scss'; @import './components/Slider.scss'; +@import './components/StagedLinkPreview.scss'; @import './components/Stories.scss'; +@import './components/StoryCreator.scss'; @import './components/StoryImage.scss'; @import './components/StoryListItem.scss'; @import './components/StoryReplyQuote.scss'; diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index de90a9b93..522214e0a 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -41,7 +41,7 @@ import { AudioCapture } from './conversation/AudioCapture'; import { CompositionUpload } from './CompositionUpload'; import type { ConversationType } from '../state/ducks/conversations'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; -import type { LinkPreviewWithDomain } from '../types/LinkPreview'; +import type { LinkPreviewType } from '../types/message/LinkPreviews'; import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions'; import { MediaQualitySelector } from './MediaQualitySelector'; @@ -102,7 +102,7 @@ export type OwnProps = Readonly<{ isSMSOnly?: boolean; left?: boolean; linkPreviewLoading: boolean; - linkPreviewResult?: LinkPreviewWithDomain; + linkPreviewResult?: LinkPreviewType; messageRequestsEnabled?: boolean; onClearAttachments(): unknown; onClickQuotedMessage(): unknown; @@ -631,10 +631,10 @@ export const CompositionArea = ({ /> )} - {linkPreviewLoading && ( + {linkPreviewLoading && linkPreviewResult && (
diff --git a/ts/components/ContextMenu.tsx b/ts/components/ContextMenu.tsx index 89edcd96b..635d062ca 100644 --- a/ts/components/ContextMenu.tsx +++ b/ts/components/ContextMenu.tsx @@ -1,7 +1,7 @@ // Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { KeyboardEvent } from 'react'; +import type { CSSProperties, KeyboardEvent } from 'react'; import type { Options } from '@popperjs/core'; import FocusTrap from 'focus-trap-react'; import React, { useEffect, useState } from 'react'; @@ -35,6 +35,7 @@ export type ContextMenuPropsType = { export type PropsType = { readonly buttonClassName?: string; + readonly buttonStyle?: CSSProperties; readonly i18n: LocalizerType; } & Pick< ContextMenuPropsType, @@ -139,6 +140,7 @@ export function ContextMenuPopper({ export function ContextMenu({ buttonClassName, + buttonStyle, i18n, menuOptions, popperOptions, @@ -208,24 +210,27 @@ export function ContextMenu({ onClick={handleClick} onKeyDown={handleKeyDown} ref={setReferenceElement} + style={buttonStyle} type="button" /> - - setMenuShowing(false)} - popperOptions={popperOptions} - referenceElement={referenceElement} - title={title} - value={value} - /> - + {menuShowing && ( + + setMenuShowing(false)} + popperOptions={popperOptions} + referenceElement={referenceElement} + title={title} + value={value} + /> + + )}
); } diff --git a/ts/components/ForwardMessageModal.tsx b/ts/components/ForwardMessageModal.tsx index 2db401e03..49d75cf84 100644 --- a/ts/components/ForwardMessageModal.tsx +++ b/ts/components/ForwardMessageModal.tsx @@ -322,13 +322,14 @@ export const ForwardMessageModal: FunctionComponent = ({ {linkPreview ? (
removeLinkPreview()} title={linkPreview.title} + url={linkPreview.url} />
) : null} diff --git a/ts/components/MediaEditor.tsx b/ts/components/MediaEditor.tsx index dd5e44e13..957464c91 100644 --- a/ts/components/MediaEditor.tsx +++ b/ts/components/MediaEditor.tsx @@ -564,7 +564,7 @@ export const MediaEditor = ({ @@ -623,7 +623,7 @@ export const MediaEditor = ({ diff --git a/ts/components/Stories.stories.tsx b/ts/components/Stories.stories.tsx index c22ef475c..e815f64ac 100644 --- a/ts/components/Stories.stories.tsx +++ b/ts/components/Stories.stories.tsx @@ -1,6 +1,7 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import type { Meta, Story } from '@storybook/react'; import React from 'react'; import { v4 as uuid } from 'uuid'; import { action } from '@storybook/addon-actions'; @@ -22,7 +23,8 @@ const i18n = setupI18n('en', enMessages); export default { title: 'Components/Stories', -}; + component: Stories, +} as Meta; function createStory({ attachment, @@ -83,6 +85,7 @@ const getDefaultProps = (): PropsType => ({ i18n, preferredWidthFromStorage: 380, queueStoryDownload: action('queueStoryDownload'), + renderStoryCreator: () =>
, renderStoryViewer: () =>
, showConversation: action('showConversation'), stories: [ @@ -127,7 +130,13 @@ const getDefaultProps = (): PropsType => ({ toggleStoriesView: action('toggleStoriesView'), }); -export const Blank = (): JSX.Element => ( - -); -export const Many = (): JSX.Element => ; +const Template: Story = args => ; + +export const Blank = Template.bind({}); +Blank.args = { + ...getDefaultProps(), + stories: [], +}; + +export const Many = Template.bind({}); +Many.args = getDefaultProps(); diff --git a/ts/components/Stories.tsx b/ts/components/Stories.tsx index 9a45ff11a..e0baccdaa 100644 --- a/ts/components/Stories.tsx +++ b/ts/components/Stories.tsx @@ -6,6 +6,7 @@ import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; import type { ConversationStoryType } from './StoryListItem'; import type { LocalizerType } from '../types/Util'; +import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator'; import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer'; import type { ShowConversationType } from '../state/ducks/conversations'; import { StoriesPane } from './StoriesPane'; @@ -18,6 +19,7 @@ export type PropsType = { i18n: LocalizerType; preferredWidthFromStorage: number; queueStoryDownload: (storyId: string) => unknown; + renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element; renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element; showConversation: ShowConversationType; stories: Array; @@ -30,6 +32,7 @@ export const Stories = ({ i18n, preferredWidthFromStorage, queueStoryDownload, + renderStoryCreator, renderStoryViewer, showConversation, stories, @@ -96,8 +99,14 @@ export const Stories = ({ setConversationIdToView(prevStory.conversationId); }, [conversationIdToView, stories]); + const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false); + return (
+ {isShowingStoryCreator && + renderStoryCreator({ + onClose: () => setIsShowingStoryCreator(false), + })} {conversationIdToView && renderStoryViewer({ conversationId: conversationIdToView, @@ -110,6 +119,7 @@ export const Stories = ({ setIsShowingStoryCreator(true)} onStoryClicked={clickedIdToView => { const storyIndex = stories.findIndex( x => x.conversationId === clickedIdToView diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx index 14ce6af31..98e222b5b 100644 --- a/ts/components/StoriesPane.tsx +++ b/ts/components/StoriesPane.tsx @@ -4,12 +4,13 @@ import Fuse from 'fuse.js'; import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; -import { isNotNil } from '../util/isNotNil'; + import type { ConversationStoryType, StoryViewType } from './StoryListItem'; import type { LocalizerType } from '../types/Util'; import type { ShowConversationType } from '../state/ducks/conversations'; import { SearchInput } from './SearchInput'; import { StoryListItem } from './StoryListItem'; +import { isNotNil } from '../util/isNotNil'; const FUSE_OPTIONS: Fuse.IFuseOptions = { getFn: (obj, path) => { @@ -53,6 +54,7 @@ function getNewestStory(story: ConversationStoryType): StoryViewType { export type PropsType = { hiddenStories: Array; i18n: LocalizerType; + onAddStory: () => unknown; onStoryClicked: (conversationId: string) => unknown; queueStoryDownload: (storyId: string) => unknown; showConversation: ShowConversationType; @@ -64,6 +66,7 @@ export type PropsType = { export const StoriesPane = ({ hiddenStories, i18n, + onAddStory, onStoryClicked, queueStoryDownload, showConversation, @@ -97,6 +100,12 @@ export const StoriesPane = ({
{i18n('Stories__title')}
+
({ + debouncedMaybeGrabLinkPreview: action('debouncedMaybeGrabLinkPreview'), + i18n, + onClose: action('onClose'), + onNext: action('onNext'), +}); + +const Template: Story = args => ; + +export const Default = Template.bind({}); +Default.args = getDefaultProps(); +Default.story = { + name: 'w/o Link Preview available', +}; + +export const LinkPreview = Template.bind({}); +LinkPreview.args = { + ...getDefaultProps(), + linkPreview: { + domain: 'www.catsandkittens.lolcats', + image: fakeAttachment({ + url: '/fixtures/kitten-4-112-112.jpg', + }), + title: 'Cats & Kittens LOL', + url: 'https://www.catsandkittens.lolcats/kittens/page/1', + }, +}; +LinkPreview.story = { + name: 'with Link Preview ready to be applied', +}; diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx new file mode 100644 index 000000000..af7913c7f --- /dev/null +++ b/ts/components/StoryCreator.tsx @@ -0,0 +1,485 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import FocusTrap from 'focus-trap-react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { get, has } from 'lodash'; +import { usePopper } from 'react-popper'; + +import type { LinkPreviewType } from '../types/message/LinkPreviews'; +import type { LocalizerType } from '../types/Util'; +import type { TextAttachmentType } from '../types/Attachment'; + +import { Button, ButtonVariant } from './Button'; +import { ContextMenu } from './ContextMenu'; +import { LinkPreviewSourceType, findLinks } from '../types/LinkPreview'; +import { Input } from './Input'; +import { Slider } from './Slider'; +import { StagedLinkPreview } from './conversation/StagedLinkPreview'; +import { TextAttachment } from './TextAttachment'; +import { Theme, themeClassName } from '../util/theme'; +import { getRGBA, getRGBANumber } from '../mediaEditor/util/color'; +import { + COLOR_BLACK_INT, + COLOR_WHITE_INT, + getBackgroundColor, +} from '../util/getStoryBackground'; +import { objectMap } from '../util/objectMap'; + +export type PropsType = { + debouncedMaybeGrabLinkPreview: ( + message: string, + source: LinkPreviewSourceType + ) => unknown; + i18n: LocalizerType; + linkPreview?: LinkPreviewType; + onClose: () => unknown; + onNext: () => unknown; +}; + +enum TextStyle { + Default, + Regular, + Bold, + Serif, + Script, + Condensed, +} + +enum TextBackground { + None, + Background, + Inverse, +} + +const BackgroundStyle = { + BG1099: { angle: 191, endColor: 4282529679, startColor: 4294260804 }, + BG1098: { startColor: 4293938406, endColor: 4279119837, angle: 192 }, + BG1031: { startColor: 4294950980, endColor: 4294859832, angle: 175 }, + BG1101: { startColor: 4278227945, endColor: 4286632135, angle: 180 }, + BG1100: { startColor: 4284861868, endColor: 4278884698, angle: 180 }, + BG1070: { color: 4294951251 }, + BG1080: { color: 4291607859 }, + BG1079: { color: 4286869806 }, + BG1083: { color: 4278825851 }, + BG1095: { color: 4287335417 }, + BG1088: { color: 4283519478 }, + BG1077: { color: 4294405742 }, + BG1094: { color: 4291315265 }, + BG1097: { color: 4291216549 }, + BG1074: { color: 4288976277 }, + BG1092: { color: 4280887593 }, +}; + +type BackgroundStyleType = typeof BackgroundStyle[keyof typeof BackgroundStyle]; + +function getBackground( + bgStyle: BackgroundStyleType +): Pick { + if (has(bgStyle, 'color')) { + return { color: get(bgStyle, 'color') }; + } + + const angle = get(bgStyle, 'angle'); + const startColor = get(bgStyle, 'startColor'); + const endColor = get(bgStyle, 'endColor'); + + return { + gradient: { angle, startColor, endColor }, + }; +} + +export const StoryCreator = ({ + debouncedMaybeGrabLinkPreview, + i18n, + linkPreview, + onClose, + onNext, +}: PropsType): JSX.Element => { + const [isEditingText, setIsEditingText] = useState(false); + const [selectedBackground, setSelectedBackground] = + useState(BackgroundStyle.BG1099); + const [textStyle, setTextStyle] = useState(TextStyle.Regular); + const [textBackground, setTextBackground] = useState( + TextBackground.None + ); + const [sliderValue, setSliderValue] = useState(0); + const [text, setText] = useState(''); + + const textEditorRef = useRef(null); + + useEffect(() => { + if (isEditingText) { + textEditorRef.current?.focus(); + } else { + textEditorRef.current?.blur(); + } + }, [isEditingText]); + + const [isColorPickerShowing, setIsColorPickerShowing] = useState(false); + const [colorPickerPopperButtonRef, setColorPickerPopperButtonRef] = + useState(null); + const [colorPickerPopperRef, setColorPickerPopperRef] = + useState(null); + + const colorPickerPopper = usePopper( + colorPickerPopperButtonRef, + colorPickerPopperRef, + { + modifiers: [ + { + name: 'arrow', + }, + ], + placement: 'top', + strategy: 'fixed', + } + ); + + const [hasLinkPreviewApplied, setHasLinkPreviewApplied] = useState(false); + const [linkPreviewInputValue, setLinkPreviewInputValue] = useState(''); + + useEffect(() => { + if (!linkPreviewInputValue) { + return; + } + debouncedMaybeGrabLinkPreview( + linkPreviewInputValue, + LinkPreviewSourceType.StoryCreator + ); + }, [debouncedMaybeGrabLinkPreview, linkPreviewInputValue]); + + useEffect(() => { + if (!text) { + return; + } + debouncedMaybeGrabLinkPreview(text, LinkPreviewSourceType.StoryCreator); + }, [debouncedMaybeGrabLinkPreview, text]); + + useEffect(() => { + if (!linkPreview || !text) { + return; + } + + const links = findLinks(text); + + const shouldApplyLinkPreview = links.includes(linkPreview.url); + setHasLinkPreviewApplied(shouldApplyLinkPreview); + }, [linkPreview, text]); + + const [isLinkPreviewInputShowing, setIsLinkPreviewInputShowing] = + useState(false); + const [linkPreviewInputPopperButtonRef, setLinkPreviewInputPopperButtonRef] = + useState(null); + const [linkPreviewInputPopperRef, setLinkPreviewInputPopperRef] = + useState(null); + + const linkPreviewInputPopper = usePopper( + linkPreviewInputPopperButtonRef, + linkPreviewInputPopperRef, + { + modifiers: [ + { + name: 'arrow', + }, + ], + placement: 'top', + strategy: 'fixed', + } + ); + + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if (!colorPickerPopperButtonRef?.contains(event.target as Node)) { + setIsColorPickerShowing(false); + event.stopPropagation(); + event.preventDefault(); + } + }; + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsColorPickerShowing(false); + event.preventDefault(); + event.stopPropagation(); + } + }; + + document.addEventListener('click', handleOutsideClick); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('click', handleOutsideClick); + document.removeEventListener('keydown', handleEscape); + }; + }, [isColorPickerShowing, colorPickerPopperButtonRef]); + + const sliderColorNumber = getRGBANumber(sliderValue); + + let textForegroundColor = sliderColorNumber; + let textBackgroundColor: number | undefined; + + if (textBackground === TextBackground.Background) { + textBackgroundColor = COLOR_WHITE_INT; + textForegroundColor = + sliderValue >= 95 ? COLOR_BLACK_INT : sliderColorNumber; + } else if (textBackground === TextBackground.Inverse) { + textBackgroundColor = + sliderValue >= 95 ? COLOR_BLACK_INT : sliderColorNumber; + textForegroundColor = COLOR_WHITE_INT; + } + + return ( + +
+
+ +
+
+ {isEditingText ? ( +
+ + setTextStyle(TextStyle.Regular), + value: TextStyle.Regular, + }, + { + icon: 'StoryCreator__icon--font-bold', + label: i18n('StoryCreator__text--bold'), + onClick: () => setTextStyle(TextStyle.Bold), + value: TextStyle.Bold, + }, + { + icon: 'StoryCreator__icon--font-serif', + label: i18n('StoryCreator__text--serif'), + onClick: () => setTextStyle(TextStyle.Serif), + value: TextStyle.Serif, + }, + { + icon: 'StoryCreator__icon--font-script', + label: i18n('StoryCreator__text--script'), + onClick: () => setTextStyle(TextStyle.Script), + value: TextStyle.Script, + }, + { + icon: 'StoryCreator__icon--font-condensed', + label: i18n('StoryCreator__text--condensed'), + onClick: () => setTextStyle(TextStyle.Condensed), + value: TextStyle.Condensed, + }, + ]} + theme={Theme.Dark} + value={textStyle} + /> +
+ ) : ( +
+ )} +
+ +
+
+ )} + + + ) : ( +
+
+ {i18n('StoryCreator__link-preview-empty')} +
+ )} +
+
+ )} +
+ +
+
+
+ + ); +}; diff --git a/ts/components/TextAttachment.tsx b/ts/components/TextAttachment.tsx index f30f23e7f..e1780218d 100644 --- a/ts/components/TextAttachment.tsx +++ b/ts/components/TextAttachment.tsx @@ -2,18 +2,21 @@ // SPDX-License-Identifier: AGPL-3.0-only import Measure from 'react-measure'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import TextareaAutosize from 'react-textarea-autosize'; import classNames from 'classnames'; import type { LocalizerType, RenderTextCallbackType } from '../types/Util'; import type { TextAttachmentType } from '../types/Attachment'; import { AddNewLines } from './conversation/AddNewLines'; import { Emojify } from './conversation/Emojify'; +import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import { TextAttachmentStyleType } from '../types/Attachment'; import { count } from '../util/grapheme'; import { getDomain } from '../types/LinkPreview'; import { getFontNameByTextScript } from '../util/getFontNameByTextScript'; import { + COLOR_WHITE_INT, getHexFromNumber, getBackgroundColor, } from '../util/getStoryBackground'; @@ -27,7 +30,6 @@ const renderNewLines: RenderTextCallbackType = ({ const CHAR_LIMIT_TEXT_LARGE = 50; const CHAR_LIMIT_TEXT_MEDIUM = 200; -const COLOR_WHITE_INT = 4294704123; const FONT_SIZE_LARGE = 64; const FONT_SIZE_MEDIUM = 42; const FONT_SIZE_SMALL = 32; @@ -40,7 +42,9 @@ enum TextSize { export type PropsType = { i18n: LocalizerType; + isEditingText?: boolean; isThumbnail?: boolean; + onChange?: (text: string) => unknown; textAttachment: TextAttachmentType; }; @@ -84,9 +88,24 @@ function getFont( return `${fontWeight}${fontSize}pt ${fontName}`; } +function getTextStyles( + textContent: string, + textForegroundColor?: number | null, + textStyle?: TextAttachmentStyleType | null, + i18n?: LocalizerType +): { color: string; font: string; textAlign: 'left' | 'center' } { + return { + color: getHexFromNumber(textForegroundColor || COLOR_WHITE_INT), + font: getFont(textContent, getTextSize(textContent), textStyle, i18n), + textAlign: getTextSize(textContent) === TextSize.Small ? 'left' : 'center', + }; +} + export const TextAttachment = ({ i18n, + isEditingText, isThumbnail, + onChange, textAttachment, }: PropsType): JSX.Element | null => { const linkPreview = useRef(null); @@ -94,6 +113,20 @@ export const TextAttachment = ({ number | undefined >(); + const textContent = textAttachment.text || ''; + + const textEditorRef = useRef(null); + + useEffect(() => { + const node = textEditorRef.current; + if (!node) { + return; + } + + node.focus(); + node.setSelectionRange(node.value.length, node.value.length); + }, [isEditingText]); + return ( {({ contentRect, measureRef }) => ( @@ -119,62 +152,72 @@ export const TextAttachment = ({ transform: `scale(${(contentRect.bounds?.height || 1) / 1280})`, }} > - {textAttachment.text && ( + {(textContent || onChange) && (
-
- onChange(ev.currentTarget.value)} + placeholder={i18n('TextAttachment__placeholder')} + ref={textEditorRef} + style={getTextStyles( + textContent, + textAttachment.textForegroundColor, + textAttachment.textStyle, + i18n + )} + value={textContent} /> -
+ ) : ( +
+ +
+ )}
)} - {textAttachment.preview && ( + {textAttachment.preview && textAttachment.preview.url && ( <> - {linkPreviewOffsetTop && - !isThumbnail && - textAttachment.preview.url && ( - -
-
{i18n('TextAttachment__preview__link')}
-
- {textAttachment.preview.url} -
+ {linkPreviewOffsetTop && !isThumbnail && ( +
+
+
{i18n('TextAttachment__preview__link')}
+
+ {textAttachment.preview.url}
-
- - )} +
+
+ + )}
-
-
- {textAttachment.preview.title && ( -
- {textAttachment.preview.title} -
- )} -
- {getDomain(String(textAttachment.preview.url))} -
-
+
)} diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 7d46bddb7..aec547125 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -1192,7 +1192,10 @@ export class Message extends React.PureComponent { /> ) : null}
- {first.image && previewHasImage && !isFullSizeImage ? ( + {first.image && + first.domain && + previewHasImage && + !isFullSizeImage ? (
= {} -): AttachmentType => ({ - contentType: stringToMIMEType( - text('attachment contentType', props.contentType || '') - ), - fileName: text('attachment fileName', props.fileName || ''), - url: text('attachment url', props.url || ''), - size: 24325, -}); - -const createProps = (overrideProps: Partial = {}): Props => ({ - title: text( - 'title', - typeof overrideProps.title === 'string' - ? overrideProps.title - : 'This is a super-sweet site' - ), - description: text( - 'description', - typeof overrideProps.description === 'string' - ? overrideProps.description - : 'This is a description' - ), - date: date('date', new Date(overrideProps.date || 0)), - domain: text('domain', overrideProps.domain || 'signal.org'), - image: overrideProps.image, +const getDefaultProps = (): Props => ({ + date: Date.now(), + description: 'This is a description', + domain: 'signal.org', i18n, onClose: action('onClose'), + title: 'This is a super-sweet site', + url: 'https://www.signal.org', }); -export const Loading = (): JSX.Element => { - const props = createProps({ domain: '' }); +const Template: Story = args => ; - return ; +export const Loading = Template.bind({}); +Loading.args = { + ...getDefaultProps(), + domain: '', }; -export const NoImage = (): JSX.Element => { - return ; +export const NoImage = Template.bind({}); + +export const Image = Template.bind({}); +Image.args = { + ...getDefaultProps(), + image: fakeAttachment({ + url: '/fixtures/kitten-4-112-112.jpg', + contentType: IMAGE_JPEG, + }), }; -export const Image = (): JSX.Element => { - const props = createProps({ - image: createAttachment({ - url: '/fixtures/kitten-4-112-112.jpg', - contentType: stringToMIMEType('image/jpeg'), - }), - }); - - return ; +export const ImageNoTitleOrDescription = Template.bind({}); +ImageNoTitleOrDescription.args = { + ...getDefaultProps(), + title: '', + description: '', + domain: 'instagram.com', + image: fakeAttachment({ + url: '/fixtures/kitten-4-112-112.jpg', + contentType: IMAGE_JPEG, + }), }; - -export const ImageNoTitleOrDescription = (): JSX.Element => { - const props = createProps({ - title: '', - description: '', - domain: 'instagram.com', - image: createAttachment({ - url: '/fixtures/kitten-4-112-112.jpg', - contentType: stringToMIMEType('image/jpeg'), - }), - }); - - return ; -}; - ImageNoTitleOrDescription.story = { name: 'Image, No Title Or Description', }; -export const NoImageLongTitleWithDescription = (): JSX.Element => { - const props = createProps({ - title: LONG_TITLE, - }); - - return ; +export const NoImageLongTitleWithDescription = Template.bind({}); +NoImageLongTitleWithDescription.args = { + ...getDefaultProps(), + title: LONG_TITLE, }; - NoImageLongTitleWithDescription.story = { name: 'No Image, Long Title With Description', }; -export const NoImageLongTitleWithoutDescription = (): JSX.Element => { - const props = createProps({ - title: LONG_TITLE, - description: '', - }); - - return ; +export const NoImageLongTitleWithoutDescription = Template.bind({}); +NoImageLongTitleWithoutDescription.args = { + ...getDefaultProps(), + title: LONG_TITLE, + description: '', }; - NoImageLongTitleWithoutDescription.story = { name: 'No Image, Long Title Without Description', }; -export const ImageLongTitleWithoutDescription = (): JSX.Element => { - const props = createProps({ - title: LONG_TITLE, - image: createAttachment({ - url: '/fixtures/kitten-4-112-112.jpg', - contentType: stringToMIMEType('image/jpeg'), - }), - }); - - return ; +export const ImageLongTitleWithoutDescription = Template.bind({}); +ImageLongTitleWithoutDescription.args = { + ...getDefaultProps(), + title: LONG_TITLE, + image: fakeAttachment({ + url: '/fixtures/kitten-4-112-112.jpg', + contentType: IMAGE_JPEG, + }), }; - ImageLongTitleWithoutDescription.story = { name: 'Image, Long Title Without Description', }; -export const ImageLongTitleAndDescription = (): JSX.Element => { - const props = createProps({ - title: LONG_TITLE, - description: LONG_DESCRIPTION, - image: createAttachment({ - url: '/fixtures/kitten-4-112-112.jpg', - contentType: stringToMIMEType('image/jpeg'), - }), - }); - - return ; +export const ImageLongTitleAndDescription = Template.bind({}); +ImageLongTitleAndDescription.args = { + ...getDefaultProps(), + title: LONG_TITLE, + description: LONG_DESCRIPTION, + image: fakeAttachment({ + url: '/fixtures/kitten-4-112-112.jpg', + contentType: IMAGE_JPEG, + }), }; - ImageLongTitleAndDescription.story = { name: 'Image, Long Title And Description', }; -export const EverythingImageTitleDescriptionAndDate = (): JSX.Element => { - const props = createProps({ - title: LONG_TITLE, - description: LONG_DESCRIPTION, - date: Date.now(), - image: createAttachment({ - url: '/fixtures/kitten-4-112-112.jpg', - contentType: stringToMIMEType('image/jpeg'), - }), - }); - - return ; +export const EverythingImageTitleDescriptionAndDate = Template.bind({}); +EverythingImageTitleDescriptionAndDate.args = { + ...getDefaultProps(), + title: LONG_TITLE, + description: LONG_DESCRIPTION, + image: fakeAttachment({ + url: '/fixtures/kitten-4-112-112.jpg', + contentType: IMAGE_JPEG, + }), }; - EverythingImageTitleDescriptionAndDate.story = { name: 'Everything: image, title, description, and date', }; diff --git a/ts/components/conversation/StagedLinkPreview.tsx b/ts/components/conversation/StagedLinkPreview.tsx index e2bc0842e..3b75a5949 100644 --- a/ts/components/conversation/StagedLinkPreview.tsx +++ b/ts/components/conversation/StagedLinkPreview.tsx @@ -8,84 +8,86 @@ import { unescape } from 'lodash'; import { CurveType, Image } from './Image'; import { LinkPreviewDate } from './LinkPreviewDate'; -import type { AttachmentType } from '../../types/Attachment'; -import { isImageAttachment } from '../../types/Attachment'; +import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type { LocalizerType } from '../../types/Util'; +import { getClassNamesFor } from '../../util/getClassNamesFor'; +import { isImageAttachment } from '../../types/Attachment'; -export type Props = { - title?: string; - description?: null | string; - date?: null | number; - domain?: string; - image?: AttachmentType; - +export type Props = LinkPreviewType & { i18n: LocalizerType; + moduleClassName?: string; onClose?: () => void; }; export const StagedLinkPreview: React.FC = ({ - onClose, - i18n, - title, - description, - image, date, + description, domain, + i18n, + image, + moduleClassName, + onClose, + title, }: Props) => { const isImage = isImageAttachment(image); const isLoaded = Boolean(domain); + const getClassName = getClassNamesFor( + 'module-staged-link-preview', + moduleClassName + ); + return (
{!isLoaded ? ( -
+
{i18n('loadingPreview')}
) : null} {isLoaded && image && isImage && domain ? ( -
+
{i18n('stagedPreviewThumbnail',
) : null} + {isLoaded && !image &&
} {isLoaded ? ( -
-
{title}
+
+
{title}
{description && ( -
+
{unescape(description)}
)} -
-
{domain}
- +
+
{domain}
+
) : null} -
); }; diff --git a/ts/mediaEditor/util/color.ts b/ts/mediaEditor/util/color.ts index 87b75a2bc..08ca1c0c1 100644 --- a/ts/mediaEditor/util/color.ts +++ b/ts/mediaEditor/util/color.ts @@ -5,29 +5,33 @@ function getRatio(min: number, max: number, value: number) { return (value - min) / (max - min); } +const MAX_BLACK = 7; +const MIN_WHITE = 95; + function getHSLValues(percentage: number): [number, number, number] { - if (percentage <= 10) { - return [0, 0, 1 - getRatio(0, 10, percentage)]; + if (percentage <= MAX_BLACK) { + return [0, 0.5, 0.5 * getRatio(0, MAX_BLACK, percentage)]; } - if (percentage < 20) { - return [0, 0.5, 0.5 * getRatio(10, 20, percentage)]; + if (percentage >= MIN_WHITE) { + return [0, 0, Math.min(1, 0.5 + getRatio(MIN_WHITE, 100, percentage))]; } - const ratio = getRatio(20, 100, percentage); + const ratio = getRatio(MAX_BLACK, MIN_WHITE, percentage); - return [360 * ratio, 1, 0.5]; -} - -export function getHSL(percentage: number): string { - const [h, s, l] = getHSLValues(percentage); - return `hsl(${h}, ${s * 100}%, ${l * 100}%)`; + return [338 * ratio, 1, 0.5]; } // https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative -export function getRGBA(percentage: number, alpha = 1): string { - const [h, s, l] = getHSLValues(percentage); - +function hslToRGB( + h: number, + s: number, + l: number +): { + r: number; + g: number; + b: number; +} { const a = s * Math.min(l, 1 - l); function f(n: number): number { @@ -35,13 +39,31 @@ export function getRGBA(percentage: number, alpha = 1): string { return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); } - const rgbValue = [ - Math.round(255 * f(0)), - Math.round(255 * f(8)), - Math.round(255 * f(4)), - ] - .map(String) - .join(','); + return { + r: Math.round(255 * f(0)), + g: Math.round(255 * f(8)), + b: Math.round(255 * f(4)), + }; +} + +export function getHSL(percentage: number): string { + const [h, s, l] = getHSLValues(percentage); + return `hsl(${h}, ${s * 100}%, ${l * 100}%)`; +} + +export function getRGBANumber(percentage: number): number { + const [h, s, l] = getHSLValues(percentage); + const { r, g, b } = hslToRGB(h, s, l); + + // eslint-disable-next-line no-bitwise + return 0x100000000 + ((255 << 24) | ((255 & r) << 16) | ((255 & g) << 8) | b); +} + +export function getRGBA(percentage: number, alpha = 1): string { + const [h, s, l] = getHSLValues(percentage); + const { r, g, b } = hslToRGB(h, s, l); + + const rgbValue = [r, g, b].map(String).join(','); return `rgba(${rgbValue},${alpha})`; } diff --git a/ts/mediaEditor/util/getTextStyleAttributes.ts b/ts/mediaEditor/util/getTextStyleAttributes.ts index e33859b4d..9083e3eb2 100644 --- a/ts/mediaEditor/util/getTextStyleAttributes.ts +++ b/ts/mediaEditor/util/getTextStyleAttributes.ts @@ -26,13 +26,13 @@ export function getTextStyleAttributes( return { fill: color, strokeWidth: 0, textBackgroundColor: '' }; case TextStyle.Highlight: return { - fill: hueSliderValue <= 5 ? '#000' : '#fff', + fill: hueSliderValue >= 95 ? '#000' : '#fff', strokeWidth: 0, textBackgroundColor: color, }; case TextStyle.Outline: return { - fill: hueSliderValue <= 5 ? '#000' : '#fff', + fill: hueSliderValue >= 95 ? '#000' : '#fff', stroke: color, strokeWidth: 2, textBackgroundColor: '', diff --git a/ts/models/messages.ts b/ts/models/messages.ts index b99c25b4c..a601ce997 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -157,6 +157,7 @@ import { SeenStatus } from '../MessageSeenStatus'; import { isNewReactionReplacingPrevious } from '../reactions/util'; import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer'; import { GiftBadgeStates } from '../components/conversation/Message'; +import { downloadAttachment } from '../util/downloadAttachment'; /* eslint-disable more/no-then */ @@ -2451,10 +2452,7 @@ export class MessageModel extends window.Backbone.Model { let hash; if (avatarAttachment) { try { - downloadedAvatar = - await window.Signal.Util.downloadAttachment( - avatarAttachment - ); + downloadedAvatar = await downloadAttachment(avatarAttachment); if (downloadedAvatar) { const loadedAttachment = diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts new file mode 100644 index 000000000..c1d557414 --- /dev/null +++ b/ts/services/LinkPreview.ts @@ -0,0 +1,532 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { debounce, omit } from 'lodash'; + +import type { LinkPreviewType } from '../types/message/LinkPreviews'; +import type { + LinkPreviewImage, + LinkPreviewResult, + LinkPreviewSourceType, +} from '../types/LinkPreview'; +import type { StickerPackType as StickerPackDBType } from '../sql/Interface'; +import type { MIMEType } from '../types/MIME'; +import * as Bytes from '../Bytes'; +import * as LinkPreview from '../types/LinkPreview'; +import * as Stickers from '../types/Stickers'; +import * as VisualAttachment from '../types/VisualAttachment'; +import * as log from '../logging/log'; +import { IMAGE_JPEG, IMAGE_WEBP, stringToMIMEType } from '../types/MIME'; +import { SECOND } from '../util/durations'; +import { autoScale } from '../util/handleImageAttachment'; +import { dropNull } from '../util/dropNull'; +import { fileToBytes } from '../util/fileToBytes'; +import { maybeParseUrl } from '../util/url'; +import { sniffImageMimeType } from '../util/sniffImageMimeType'; + +const LINK_PREVIEW_TIMEOUT = 60 * SECOND; + +let currentlyMatchedLink: string | undefined; +let disableLinkPreviews = false; +let excludedPreviewUrls: Array = []; +let linkPreviewAbortController: AbortController | undefined; +let linkPreviewResult: Array | undefined; + +export function suspendLinkPreviews(): void { + disableLinkPreviews = true; +} + +export function hasLinkPreviewLoaded(): boolean { + return Boolean(linkPreviewResult); +} + +export const maybeGrabLinkPreview = debounce(_maybeGrabLinkPreview, 200); + +function _maybeGrabLinkPreview( + message: string, + source: LinkPreviewSourceType, + caretLocation?: number +): void { + // Don't generate link previews if user has turned them off + if (!window.Events.getLinkPreviewSetting()) { + return; + } + + // Do nothing if we're offline + const { messaging } = window.textsecure; + if (!messaging) { + return; + } + // If we're behind a user-configured proxy, we don't support link previews + if (window.isBehindProxy()) { + return; + } + + if (!message) { + resetLinkPreview(); + return; + } + + if (disableLinkPreviews) { + return; + } + + const links = LinkPreview.findLinks(message, caretLocation); + if (currentlyMatchedLink && links.includes(currentlyMatchedLink)) { + return; + } + + currentlyMatchedLink = undefined; + excludedPreviewUrls = excludedPreviewUrls || []; + + const link = links.find( + item => + LinkPreview.shouldPreviewHref(item) && !excludedPreviewUrls.includes(item) + ); + if (!link) { + removeLinkPreview(); + return; + } + + addLinkPreview(link, source); +} + +export function resetLinkPreview(): void { + disableLinkPreviews = false; + excludedPreviewUrls = []; + removeLinkPreview(); +} + +export function removeLinkPreview(): void { + (linkPreviewResult || []).forEach((item: LinkPreviewResult) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + }); + linkPreviewResult = undefined; + currentlyMatchedLink = undefined; + linkPreviewAbortController?.abort(); + linkPreviewAbortController = undefined; + + window.reduxActions.linkPreviews.removeLinkPreview(); +} + +export async function addLinkPreview( + url: string, + source: LinkPreviewSourceType +): Promise { + if (currentlyMatchedLink === url) { + log.warn('addLinkPreview should not be called with the same URL like this'); + return; + } + + (linkPreviewResult || []).forEach((item: LinkPreviewResult) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + }); + window.reduxActions.linkPreviews.removeLinkPreview(); + linkPreviewResult = undefined; + + // Cancel other in-flight link preview requests. + if (linkPreviewAbortController) { + log.info( + 'addLinkPreview: canceling another in-flight link preview request' + ); + linkPreviewAbortController.abort(); + } + + const thisRequestAbortController = new AbortController(); + linkPreviewAbortController = thisRequestAbortController; + + const timeout = setTimeout(() => { + thisRequestAbortController.abort(); + }, LINK_PREVIEW_TIMEOUT); + + currentlyMatchedLink = url; + // Adding just the URL so that we get into a "loading" state + window.reduxActions.linkPreviews.addLinkPreview( + { + url, + }, + source + ); + + try { + const result = await getPreview(url, thisRequestAbortController.signal); + + if (!result) { + log.info( + 'addLinkPreview: failed to load preview (not necessarily a problem)' + ); + + // This helps us disambiguate between two kinds of failure: + // + // 1. We failed to fetch the preview because of (1) a network failure (2) an + // invalid response (3) a timeout + // 2. We failed to fetch the preview because we aborted the request because the + // user changed the link (e.g., by continuing to type the URL) + const failedToFetch = currentlyMatchedLink === url; + if (failedToFetch) { + excludedPreviewUrls.push(url); + removeLinkPreview(); + } + return; + } + + if (result.image && result.image.data) { + const blob = new Blob([result.image.data], { + type: result.image.contentType, + }); + result.image.url = URL.createObjectURL(blob); + } else if (!result.title) { + // A link preview isn't worth showing unless we have either a title or an image + removeLinkPreview(); + return; + } + + window.reduxActions.linkPreviews.addLinkPreview( + { + ...result, + description: dropNull(result.description), + date: dropNull(result.date), + domain: LinkPreview.getDomain(result.url), + isStickerPack: LinkPreview.isStickerPack(result.url), + }, + source + ); + linkPreviewResult = [result]; + } catch (error) { + log.error( + 'Problem loading link preview, disabling.', + error && error.stack ? error.stack : error + ); + disableLinkPreviews = true; + removeLinkPreview(); + } finally { + clearTimeout(timeout); + } +} + +export function getLinkPreviewForSend(message: string): Array { + // Don't generate link previews if user has turned them off + if (!window.storage.get('linkPreviews', false)) { + return []; + } + + if (!linkPreviewResult) { + return []; + } + + const urlsInMessage = new Set(LinkPreview.findLinks(message)); + + return ( + linkPreviewResult + // This bullet-proofs against sending link previews for URLs that are no longer in + // the message. This can happen if you have a link preview, then quickly delete + // the link and send the message. + .filter(({ url }: Readonly<{ url: string }>) => urlsInMessage.has(url)) + .map((item: LinkPreviewResult) => { + if (item.image) { + // We eliminate the ObjectURL here, unneeded for send or save + return { + ...item, + image: omit(item.image, 'url'), + description: dropNull(item.description), + date: dropNull(item.date), + domain: LinkPreview.getDomain(item.url), + isStickerPack: LinkPreview.isStickerPack(item.url), + }; + } + + return { + ...item, + description: dropNull(item.description), + date: dropNull(item.date), + domain: LinkPreview.getDomain(item.url), + isStickerPack: LinkPreview.isStickerPack(item.url), + }; + }) + ); +} + +async function getPreview( + url: string, + abortSignal: Readonly +): Promise { + const { messaging } = window.textsecure; + + if (!messaging) { + throw new Error('messaging is not available!'); + } + + if (LinkPreview.isStickerPack(url)) { + return getStickerPackPreview(url, abortSignal); + } + if (LinkPreview.isGroupLink(url)) { + return getGroupPreview(url, abortSignal); + } + + // This is already checked elsewhere, but we want to be extra-careful. + if (!LinkPreview.shouldPreviewHref(url)) { + return null; + } + + const linkPreviewMetadata = await messaging.fetchLinkPreviewMetadata( + url, + abortSignal + ); + if (!linkPreviewMetadata || abortSignal.aborted) { + return null; + } + const { title, imageHref, description, date } = linkPreviewMetadata; + + let image; + if (imageHref && LinkPreview.shouldPreviewHref(imageHref)) { + let objectUrl: void | string; + try { + const fullSizeImage = await messaging.fetchLinkPreviewImage( + imageHref, + abortSignal + ); + if (abortSignal.aborted) { + return null; + } + if (!fullSizeImage) { + throw new Error('Failed to fetch link preview image'); + } + + // Ensure that this file is either small enough or is resized to meet our + // requirements for attachments + const withBlob = await autoScale({ + contentType: fullSizeImage.contentType, + file: new Blob([fullSizeImage.data], { + type: fullSizeImage.contentType, + }), + fileName: title, + }); + + const data = await fileToBytes(withBlob.file); + objectUrl = URL.createObjectURL(withBlob.file); + + const blurHash = await window.imageToBlurHash(withBlob.file); + + const dimensions = await VisualAttachment.getImageDimensions({ + objectUrl, + logger: log, + }); + + image = { + data, + size: data.byteLength, + ...dimensions, + contentType: stringToMIMEType(withBlob.file.type), + blurHash, + }; + } catch (error) { + // We still want to show the preview if we failed to get an image + log.error( + 'getPreview failed to get image for link preview:', + error.message + ); + } finally { + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + } + } + + if (abortSignal.aborted) { + return null; + } + + return { + date: date || null, + description: description || null, + image, + title, + url, + }; +} + +async function getStickerPackPreview( + url: string, + abortSignal: Readonly +): Promise { + const isPackDownloaded = ( + pack?: StickerPackDBType + ): pack is StickerPackDBType => { + if (!pack) { + return false; + } + + return pack.status === 'downloaded' || pack.status === 'installed'; + }; + const isPackValid = (pack?: StickerPackDBType): pack is StickerPackDBType => { + if (!pack) { + return false; + } + return ( + pack.status === 'ephemeral' || + pack.status === 'downloaded' || + pack.status === 'installed' + ); + }; + + const dataFromLink = Stickers.getDataFromLink(url); + if (!dataFromLink) { + return null; + } + const { id, key } = dataFromLink; + + try { + const keyBytes = Bytes.fromHex(key); + const keyBase64 = Bytes.toBase64(keyBytes); + + const existing = Stickers.getStickerPack(id); + if (!isPackDownloaded(existing)) { + await Stickers.downloadEphemeralPack(id, keyBase64); + } + + if (abortSignal.aborted) { + return null; + } + + const pack = Stickers.getStickerPack(id); + + if (!isPackValid(pack)) { + return null; + } + if (pack.key !== keyBase64) { + return null; + } + + const { title, coverStickerId } = pack; + const sticker = pack.stickers[coverStickerId]; + const data = + pack.status === 'ephemeral' + ? await window.Signal.Migrations.readTempData(sticker.path) + : await window.Signal.Migrations.readStickerData(sticker.path); + + if (abortSignal.aborted) { + return null; + } + + let contentType: MIMEType; + const sniffedMimeType = sniffImageMimeType(data); + if (sniffedMimeType) { + contentType = sniffedMimeType; + } else { + log.warn( + 'getStickerPackPreview: Unable to sniff sticker MIME type; falling back to WebP' + ); + contentType = IMAGE_WEBP; + } + + return { + date: null, + description: null, + image: { + ...sticker, + data, + size: data.byteLength, + contentType, + }, + title, + url, + }; + } catch (error) { + log.error( + 'getStickerPackPreview error:', + error && error.stack ? error.stack : error + ); + return null; + } finally { + if (id) { + await Stickers.removeEphemeralPack(id); + } + } +} + +async function getGroupPreview( + url: string, + abortSignal: Readonly +): Promise { + const urlObject = maybeParseUrl(url); + if (!urlObject) { + return null; + } + + const { hash } = urlObject; + if (!hash) { + return null; + } + const groupData = hash.slice(1); + + const { inviteLinkPassword, masterKey } = + window.Signal.Groups.parseGroupLink(groupData); + + const fields = window.Signal.Groups.deriveGroupFields( + Bytes.fromBase64(masterKey) + ); + const id = Bytes.toBase64(fields.id); + const logId = `groupv2(${id})`; + const secretParams = Bytes.toBase64(fields.secretParams); + + log.info(`getGroupPreview/${logId}: Fetching pre-join state`); + const result = await window.Signal.Groups.getPreJoinGroupInfo( + inviteLinkPassword, + masterKey + ); + + if (abortSignal.aborted) { + return null; + } + + const title = + window.Signal.Groups.decryptGroupTitle(result.title, secretParams) || + window.i18n('unknownGroup'); + const description = + result.memberCount === 1 || result.memberCount === undefined + ? window.i18n('GroupV2--join--member-count--single') + : window.i18n('GroupV2--join--member-count--multiple', { + count: result.memberCount.toString(), + }); + let image: undefined | LinkPreviewImage; + + if (result.avatar) { + try { + const data = await window.Signal.Groups.decryptGroupAvatar( + result.avatar, + secretParams + ); + image = { + data, + size: data.byteLength, + contentType: IMAGE_JPEG, + blurHash: await window.imageToBlurHash( + new Blob([data], { + type: IMAGE_JPEG, + }) + ), + }; + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + log.error( + `getGroupPreview/${logId}: Failed to fetch avatar ${errorString}` + ); + } + } + + if (abortSignal.aborted) { + return null; + } + + return { + date: null, + description, + image, + title, + url, + }; +} diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index b9907c320..0552b653d 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -11,23 +11,30 @@ import type { InMemoryAttachmentDraftType, } from '../../types/Attachment'; import type { MessageAttributesType } from '../../model-types.d'; -import type { LinkPreviewWithDomain } from '../../types/LinkPreview'; +import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnecessaryAllocation'; -import type { RemoveLinkPreviewActionType } from './linkPreviews'; -import { REMOVE_PREVIEW as REMOVE_LINK_PREVIEW } from './linkPreviews'; +import type { + AddLinkPreviewActionType, + RemoveLinkPreviewActionType, +} from './linkPreviews'; +import { + ADD_PREVIEW as ADD_LINK_PREVIEW, + REMOVE_PREVIEW as REMOVE_LINK_PREVIEW, +} from './linkPreviews'; import { writeDraftAttachment } from '../../util/writeDraftAttachment'; import { deleteDraftAttachment } from '../../util/deleteDraftAttachment'; import { replaceIndex } from '../../util/replaceIndex'; import { resolveDraftAttachmentOnDisk } from '../../util/resolveDraftAttachmentOnDisk'; import type { HandleAttachmentsProcessingArgsType } from '../../util/handleAttachmentsProcessing'; import { handleAttachmentsProcessing } from '../../util/handleAttachmentsProcessing'; +import { LinkPreviewSourceType } from '../../types/LinkPreview'; // State export type ComposerStateType = { attachments: ReadonlyArray; linkPreviewLoading: boolean; - linkPreviewResult?: LinkPreviewWithDomain; + linkPreviewResult?: LinkPreviewType; quotedMessage?: Pick; shouldSendHighQualityAttachments: boolean; }; @@ -38,7 +45,6 @@ const ADD_PENDING_ATTACHMENT = 'composer/ADD_PENDING_ATTACHMENT'; const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS'; const RESET_COMPOSER = 'composer/RESET_COMPOSER'; const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING'; -const SET_LINK_PREVIEW_RESULT = 'composer/SET_LINK_PREVIEW_RESULT'; const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE'; type AddPendingAttachmentActionType = { @@ -60,26 +66,18 @@ type SetHighQualitySettingActionType = { payload: boolean; }; -type SetLinkPreviewResultActionType = { - type: typeof SET_LINK_PREVIEW_RESULT; - payload: { - isLoading: boolean; - linkPreview?: LinkPreviewWithDomain; - }; -}; - type SetQuotedMessageActionType = { type: typeof SET_QUOTED_MESSAGE; payload?: Pick; }; type ComposerActionType = + | AddLinkPreviewActionType | AddPendingAttachmentActionType | RemoveLinkPreviewActionType | ReplaceAttachmentsActionType | ResetComposerActionType | SetHighQualitySettingActionType - | SetLinkPreviewResultActionType | SetQuotedMessageActionType; // Action Creators @@ -91,7 +89,6 @@ export const actions = { removeAttachment, replaceAttachments, resetComposer, - setLinkPreviewResult, setMediaQualitySetting, setQuotedMessage, }; @@ -266,19 +263,6 @@ function resetComposer(): ResetComposerActionType { }; } -function setLinkPreviewResult( - isLoading: boolean, - linkPreview?: LinkPreviewWithDomain -): SetLinkPreviewResultActionType { - return { - type: SET_LINK_PREVIEW_RESULT, - payload: { - isLoading, - linkPreview, - }, - }; -} - function setMediaQualitySetting( payload: boolean ): SetHighQualitySettingActionType { @@ -340,10 +324,14 @@ export function reducer( }; } - if (action.type === SET_LINK_PREVIEW_RESULT) { + if (action.type === ADD_LINK_PREVIEW) { + if (action.payload.source !== LinkPreviewSourceType.Composer) { + return state; + } + return { ...state, - linkPreviewLoading: action.payload.isLoading, + linkPreviewLoading: true, linkPreviewResult: action.payload.linkPreview, }; } diff --git a/ts/state/ducks/linkPreviews.ts b/ts/state/ducks/linkPreviews.ts index e63ca5c45..a19b9e330 100644 --- a/ts/state/ducks/linkPreviews.ts +++ b/ts/state/ducks/linkPreviews.ts @@ -1,23 +1,34 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import type { ThunkAction } from 'redux-thunk'; + +import type { NoopActionType } from './noop'; +import type { StateType as RootStateType } from '../reducer'; import type { LinkPreviewType } from '../../types/message/LinkPreviews'; +import type { LinkPreviewSourceType } from '../../types/LinkPreview'; import { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnecessaryAllocation'; +import { maybeGrabLinkPreview } from '../../services/LinkPreview'; +import { useBoundActions } from '../../hooks/useBoundActions'; // State export type LinkPreviewsStateType = { readonly linkPreview?: LinkPreviewType; + readonly source?: LinkPreviewSourceType; }; // Actions -const ADD_PREVIEW = 'linkPreviews/ADD_PREVIEW'; +export const ADD_PREVIEW = 'linkPreviews/ADD_PREVIEW'; export const REMOVE_PREVIEW = 'linkPreviews/REMOVE_PREVIEW'; -type AddLinkPreviewActionType = { +export type AddLinkPreviewActionType = { type: 'linkPreviews/ADD_PREVIEW'; - payload: LinkPreviewType; + payload: { + linkPreview: LinkPreviewType; + source: LinkPreviewSourceType; + }; }; export type RemoveLinkPreviewActionType = { @@ -30,15 +41,30 @@ type LinkPreviewsActionType = // Action Creators -export const actions = { - addLinkPreview, - removeLinkPreview, -}; +function debouncedMaybeGrabLinkPreview( + message: string, + source: LinkPreviewSourceType +): ThunkAction { + return dispatch => { + maybeGrabLinkPreview(message, source); -function addLinkPreview(payload: LinkPreviewType): AddLinkPreviewActionType { + dispatch({ + type: 'NOOP', + payload: null, + }); + }; +} + +function addLinkPreview( + linkPreview: LinkPreviewType, + source: LinkPreviewSourceType +): AddLinkPreviewActionType { return { type: ADD_PREVIEW, - payload, + payload: { + linkPreview, + source, + }, }; } @@ -48,6 +74,15 @@ function removeLinkPreview(): RemoveLinkPreviewActionType { }; } +export const actions = { + addLinkPreview, + debouncedMaybeGrabLinkPreview, + removeLinkPreview, +}; + +export const useLinkPreviewActions = (): typeof actions => + useBoundActions(actions); + // Reducer export function getEmptyState(): LinkPreviewsStateType { @@ -64,13 +99,15 @@ export function reducer( const { payload } = action; return { - linkPreview: payload, + linkPreview: payload.linkPreview, + source: payload.source, }; } if (action.type === REMOVE_PREVIEW) { return assignWithNoUnnecessaryAllocation(state, { linkPreview: undefined, + source: undefined, }); } diff --git a/ts/state/selectors/linkPreviews.ts b/ts/state/selectors/linkPreviews.ts index dc4b1c093..107218a1c 100644 --- a/ts/state/selectors/linkPreviews.ts +++ b/ts/state/selectors/linkPreviews.ts @@ -6,12 +6,21 @@ import { createSelector } from 'reselect'; import { assert } from '../../util/assert'; import { getDomain } from '../../types/LinkPreview'; +import type { LinkPreviewSourceType } from '../../types/LinkPreview'; import type { StateType } from '../reducer'; export const getLinkPreview = createSelector( - ({ linkPreviews }: StateType) => linkPreviews.linkPreview, - linkPreview => { - if (linkPreview) { + ({ linkPreviews }: StateType) => linkPreviews, + ({ linkPreview, source }) => { + return (fromSource: LinkPreviewSourceType) => { + if (!linkPreview) { + return; + } + + if (source !== fromSource) { + return; + } + const domain = getDomain(linkPreview.url); assert(domain !== undefined, "Domain of linkPreview can't be undefined"); @@ -20,8 +29,6 @@ export const getLinkPreview = createSelector( domain, isLoaded: true, }; - } - - return undefined; + }; } ); diff --git a/ts/state/smart/ForwardMessageModal.tsx b/ts/state/smart/ForwardMessageModal.tsx index b2d1d4123..69c3ee8f3 100644 --- a/ts/state/smart/ForwardMessageModal.tsx +++ b/ts/state/smart/ForwardMessageModal.tsx @@ -2,19 +2,20 @@ // SPDX-License-Identifier: AGPL-3.0-only import { connect } from 'react-redux'; -import { mapDispatchToProps } from '../actions'; -import type { DataPropsType } from '../../components/ForwardMessageModal'; -import { ForwardMessageModal } from '../../components/ForwardMessageModal'; -import type { StateType } from '../reducer'; -import type { BodyRangeType } from '../../types/Util'; -import type { LinkPreviewType } from '../../types/message/LinkPreviews'; -import { getPreferredBadgeSelector } from '../selectors/badges'; -import { getAllComposableConversations } from '../selectors/conversations'; -import { getLinkPreview } from '../selectors/linkPreviews'; -import { getIntl, getTheme, getRegionCode } from '../selectors/user'; -import { getEmojiSkinTone } from '../selectors/items'; -import { selectRecentEmojis } from '../selectors/emojis'; import type { AttachmentType } from '../../types/Attachment'; +import type { BodyRangeType } from '../../types/Util'; +import type { DataPropsType } from '../../components/ForwardMessageModal'; +import type { LinkPreviewType } from '../../types/message/LinkPreviews'; +import type { StateType } from '../reducer'; +import { ForwardMessageModal } from '../../components/ForwardMessageModal'; +import { LinkPreviewSourceType } from '../../types/LinkPreview'; +import { getAllComposableConversations } from '../selectors/conversations'; +import { getEmojiSkinTone } from '../selectors/items'; +import { getIntl, getTheme, getRegionCode } from '../selectors/user'; +import { getLinkPreview } from '../selectors/linkPreviews'; +import { getPreferredBadgeSelector } from '../selectors/badges'; +import { mapDispatchToProps } from '../actions'; +import { selectRecentEmojis } from '../selectors/emojis'; export type SmartForwardMessageModalProps = { attachments?: Array; @@ -54,7 +55,7 @@ const mapStateToProps = ( const candidateConversations = getAllComposableConversations(state); const recentEmojis = selectRecentEmojis(state); const skinTone = getEmojiSkinTone(state); - const linkPreview = getLinkPreview(state); + const linkPreviewForSource = getLinkPreview(state); return { attachments, @@ -64,7 +65,9 @@ const mapStateToProps = ( hasContact, i18n: getIntl(state), isSticker, - linkPreview, + linkPreview: linkPreviewForSource( + LinkPreviewSourceType.ForwardMessageModal + ), messageBody, onClose, onEditorStateChange, diff --git a/ts/state/smart/Stories.tsx b/ts/state/smart/Stories.tsx index b04e9fa1e..498353630 100644 --- a/ts/state/smart/Stories.tsx +++ b/ts/state/smart/Stories.tsx @@ -6,7 +6,9 @@ import { useSelector } from 'react-redux'; import type { LocalizerType } from '../../types/Util'; import type { StateType } from '../reducer'; +import type { PropsType as SmartStoryCreatorPropsType } from './StoryCreator'; import type { PropsType as SmartStoryViewerPropsType } from './StoryViewer'; +import { SmartStoryCreator } from './StoryCreator'; import { SmartStoryViewer } from './StoryViewer'; import { Stories } from '../../components/Stories'; import { getIntl } from '../selectors/user'; @@ -15,6 +17,12 @@ import { getStories } from '../selectors/stories'; import { useStoriesActions } from '../ducks/stories'; import { useConversationsActions } from '../ducks/conversations'; +function renderStoryCreator({ + onClose, +}: SmartStoryCreatorPropsType): JSX.Element { + return ; +} + function renderStoryViewer({ conversationId, onClose, @@ -56,6 +64,7 @@ export function SmartStories(): JSX.Element | null { hiddenStories={hiddenStories} i18n={i18n} preferredWidthFromStorage={preferredWidthFromStorage} + renderStoryCreator={renderStoryCreator} renderStoryViewer={renderStoryViewer} showConversation={showConversation} stories={stories} diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx new file mode 100644 index 000000000..8aeb0a23d --- /dev/null +++ b/ts/state/smart/StoryCreator.tsx @@ -0,0 +1,35 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { useSelector } from 'react-redux'; +import { noop } from 'lodash'; + +import type { LocalizerType } from '../../types/Util'; +import type { StateType } from '../reducer'; +import { LinkPreviewSourceType } from '../../types/LinkPreview'; +import { StoryCreator } from '../../components/StoryCreator'; +import { getIntl } from '../selectors/user'; +import { getLinkPreview } from '../selectors/linkPreviews'; +import { useLinkPreviewActions } from '../ducks/linkPreviews'; + +export type PropsType = { + onClose: () => unknown; +}; + +export function SmartStoryCreator({ onClose }: PropsType): JSX.Element | null { + const { debouncedMaybeGrabLinkPreview } = useLinkPreviewActions(); + + const i18n = useSelector(getIntl); + const linkPreviewForSource = useSelector(getLinkPreview); + + return ( + + ); +} diff --git a/ts/test-both/state/ducks/composer_test.ts b/ts/test-both/state/ducks/composer_test.ts index 384241026..2e02bff92 100644 --- a/ts/test-both/state/ducks/composer_test.ts +++ b/ts/test-both/state/ducks/composer_test.ts @@ -117,35 +117,6 @@ describe('both/state/ducks/composer', () => { }); }); - describe('setLinkPreviewResult', () => { - it('sets loading state when loading', () => { - const { setLinkPreviewResult } = actions; - const state = getEmptyState(); - const nextState = reducer(state, setLinkPreviewResult(true)); - - assert.isTrue(nextState.linkPreviewLoading); - }); - - it('sets the link preview result', () => { - const { setLinkPreviewResult } = actions; - const state = getEmptyState(); - const nextState = reducer( - state, - setLinkPreviewResult(false, { - domain: 'https://www.signal.org/', - title: 'Signal >> Careers', - url: 'https://www.signal.org/workworkwork', - description: - 'Join an organization that empowers users by making private communication simple.', - date: null, - }) - ); - - assert.isFalse(nextState.linkPreviewLoading); - assert.equal(nextState.linkPreviewResult?.title, 'Signal >> Careers'); - }); - }); - describe('setMediaQualitySetting', () => { it('toggles the media quality setting', () => { const { setMediaQualitySetting } = actions; diff --git a/ts/test-both/state/ducks/linkPreviews_test.ts b/ts/test-both/state/ducks/linkPreviews_test.ts index a43307148..d31c84f4f 100644 --- a/ts/test-both/state/ducks/linkPreviews_test.ts +++ b/ts/test-both/state/ducks/linkPreviews_test.ts @@ -26,7 +26,7 @@ describe('both/state/ducks/linkPreviews', () => { it('updates linkPreview', () => { const state = getEmptyState(); const linkPreview = getMockLinkPreview(); - const nextState = reducer(state, addLinkPreview(linkPreview)); + const nextState = reducer(state, addLinkPreview(linkPreview, 0)); assert.strictEqual(nextState.linkPreview, linkPreview); }); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index b69094f23..1ea0ec7a0 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1806,6 +1806,7 @@ export default class MessageReceiver throw new Error('Text attachments must have text!'); } + // TODO DESKTOP-3714 we should download the story link preview image attachments.push({ size: text.length, contentType: APPLICATION_OCTET_STREAM, diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 338e9a316..30f83cd3b 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -108,7 +108,7 @@ export type ProcessedAttachment = { caption?: string; blurHash?: string; cdnNumber?: number; - textAttachment?: TextAttachmentType; + textAttachment?: Omit; }; export type ProcessedGroupContext = { diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 73af2e160..6a8de05ce 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -102,8 +102,9 @@ export type TextAttachmentType = { textForegroundColor?: number | null; textBackgroundColor?: number | null; preview?: { - url?: string | null; + image?: AttachmentType; title?: string | null; + url?: string | null; } | null; gradient?: { startColor?: number | null; diff --git a/ts/types/LinkPreview.ts b/ts/types/LinkPreview.ts index 7f9e9fc47..7b2964a16 100644 --- a/ts/types/LinkPreview.ts +++ b/ts/types/LinkPreview.ts @@ -26,6 +26,12 @@ export type LinkPreviewWithDomain = { domain: string; } & LinkPreviewResult; +export enum LinkPreviewSourceType { + Composer, + ForwardMessageModal, + StoryCreator, +} + const linkify = LinkifyIt(); export function shouldPreviewHref(href: string): boolean { diff --git a/ts/types/message/LinkPreviews.ts b/ts/types/message/LinkPreviews.ts index 58fa5c781..ba163b20d 100644 --- a/ts/types/message/LinkPreviews.ts +++ b/ts/types/message/LinkPreviews.ts @@ -4,9 +4,9 @@ import type { AttachmentType } from '../Attachment'; export type LinkPreviewType = { - title: string; + title?: string; description?: string; - domain: string; + domain?: string; url: string; isStickerPack?: boolean; image?: Readonly; diff --git a/ts/util/getStoryBackground.ts b/ts/util/getStoryBackground.ts index 1e5601f5b..53fb02dc1 100644 --- a/ts/util/getStoryBackground.ts +++ b/ts/util/getStoryBackground.ts @@ -4,7 +4,8 @@ import type { AttachmentType, TextAttachmentType } from '../types/Attachment'; const COLOR_BLACK_ALPHA_90 = 'rgba(0, 0, 0, 0.9)'; -const COLOR_WHITE_INT = 4294704123; +export const COLOR_BLACK_INT = 4278190080; +export const COLOR_WHITE_INT = 4294704123; export function getHexFromNumber(color: number): string { return `#${color.toString(16).slice(2)}`; @@ -13,11 +14,11 @@ export function getHexFromNumber(color: number): string { export function getBackgroundColor({ color, gradient, -}: TextAttachmentType): string { +}: Pick): string { if (gradient) { return `linear-gradient(${gradient.angle}deg, ${getHexFromNumber( gradient.startColor || COLOR_WHITE_INT - )}, ${getHexFromNumber(gradient.endColor || COLOR_WHITE_INT)})`; + )}, ${getHexFromNumber(gradient.endColor || COLOR_WHITE_INT)}) border-box`; } return getHexFromNumber(color || COLOR_WHITE_INT); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index ba9aef5ea..f01f8ab70 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -912,7 +912,7 @@ "rule": "jQuery-load(", "path": "node_modules/agent-base/node_modules/debug/src/common.js", "line": "\tcreateDebug.enable(createDebug.load());", - "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "reasonCategory": "usageTrusted", "updated": "2022-02-11T21:58:24.827Z" }, { @@ -7351,6 +7351,125 @@ "reasonCategory": "falseMatch", "updated": "2022-06-04T00:50:49.405Z" }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.cjs.js", + "line": " var libRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.cjs.js", + "line": " var heightRef = React.useRef(0);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.cjs.js", + "line": " var measurementsCacheRef = React.useRef();", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.esm.js", + "line": " var libRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.esm.js", + "line": " var heightRef = useRef(0);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.esm.js", + "line": " var measurementsCacheRef = useRef();", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.dev.js", + "line": " var libRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.dev.js", + "line": " var heightRef = React.useRef(0);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.dev.js", + "line": " var measurementsCacheRef = React.useRef();", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.prod.js", + "line": " var libRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.prod.js", + "line": " var heightRef = React.useRef(0);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.prod.js", + "line": " var measurementsCacheRef = React.useRef();", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.esm.js", + "line": " var libRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.esm.js", + "line": " var heightRef = useRef(0);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.esm.js", + "line": " var measurementsCacheRef = useRef();", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "jQuery-wrap(", + "path": "node_modules/react-textarea-autosize/node_modules/regenerator-runtime/runtime.js", + "line": " function wrap(innerFn, outerFn, self, tryLocsList) {", + "reasonCategory": "falseMatch", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "jQuery-wrap(", + "path": "node_modules/react-textarea-autosize/node_modules/regenerator-runtime/runtime.js", + "line": " wrap(innerFn, outerFn, self, tryLocsList),", + "reasonCategory": "falseMatch", + "updated": "2022-06-16T23:23:32.306Z" + }, { "rule": "jQuery-wrap(", "path": "node_modules/redux/node_modules/regenerator-runtime/runtime.js", @@ -8108,6 +8227,41 @@ "updated": "2020-08-26T00:10:28.628Z", "reasonDetail": "isn't jquery" }, + { + "rule": "React-useRef", + "path": "node_modules/use-composed-ref/dist/use-composed-ref.cjs.js", + "line": " var prevUserRef = React.useRef();", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/use-composed-ref/dist/use-composed-ref.esm.js", + "line": " var prevUserRef = useRef();", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/use-latest/dist/use-latest.cjs.dev.js", + "line": " var ref = React__namespace.useRef(value);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/use-latest/dist/use-latest.cjs.prod.js", + "line": " var ref = React__namespace.useRef(value);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, + { + "rule": "React-useRef", + "path": "node_modules/use-latest/dist/use-latest.esm.js", + "line": " var ref = React.useRef(value);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, { "rule": "eval", "path": "node_modules/vm2/lib/nodevm.js", @@ -8751,6 +8905,13 @@ "reasonCategory": "usageTrusted", "updated": "2021-11-30T10:15:33.662Z" }, + { + "rule": "React-useRef", + "path": "ts/components/StoryCreator.tsx", + "line": " const textEditorRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, { "rule": "React-useRef", "path": "ts/components/StoryImage.tsx", @@ -8779,6 +8940,13 @@ "reasonCategory": "usageTrusted", "updated": "2022-04-06T00:59:17.194Z" }, + { + "rule": "React-useRef", + "path": "ts/components/TextAttachment.tsx", + "line": " const textEditorRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2022-06-16T23:23:32.306Z" + }, { "rule": "React-useRef", "path": "ts/components/Tooltip.tsx", diff --git a/ts/util/objectMap.ts b/ts/util/objectMap.ts new file mode 100644 index 000000000..66a82d1e8 --- /dev/null +++ b/ts/util/objectMap.ts @@ -0,0 +1,10 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function objectMap( + obj: Record, + f: (key: keyof typeof obj, value: typeof obj[keyof typeof obj]) => unknown +): Array { + const keys: Array = Object.keys(obj); + return keys.map(key => f(key, obj[key])); +} diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index bc963ba79..33b47d03a 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -6,18 +6,15 @@ import type * as Backbone from 'backbone'; import type { ComponentProps } from 'react'; import * as React from 'react'; -import { debounce, flatten, omit, throttle } from 'lodash'; +import { debounce, flatten, throttle } from 'lodash'; import { render } from 'mustache'; import type { AttachmentType } from '../types/Attachment'; import { isGIF } from '../types/Attachment'; import * as Attachment from '../types/Attachment'; -import type { StickerPackType as StickerPackDBType } from '../sql/Interface'; import * as Stickers from '../types/Stickers'; import type { BodyRangeType, BodyRangesType } from '../types/Util'; import type { MIMEType } from '../types/MIME'; -import { IMAGE_JPEG, IMAGE_WEBP, stringToMIMEType } from '../types/MIME'; -import { sniffImageMimeType } from '../util/sniffImageMimeType'; import type { ConversationModel } from '../models/conversations'; import type { GroupV2PendingMemberType, @@ -31,7 +28,6 @@ import type { MessageModel } from '../models/messages'; import { getMessageById } from '../messages/getMessageById'; import { getContactId } from '../messages/helpers'; import { strictAssert } from '../util/assert'; -import { maybeParseUrl } from '../util/url'; import { enqueueReactionForSend } from '../reactions/enqueueReactionForSend'; import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob'; import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue'; @@ -42,7 +38,6 @@ import { isGroupV1, } from '../util/whatTypeOfConversation'; import { findAndFormatContact } from '../util/findAndFormatContact'; -import * as Bytes from '../Bytes'; import { getPreferredBadgeSelector } from '../state/selectors/badges'; import { canReply, @@ -61,13 +56,6 @@ import { ReactWrapperView } from './ReactWrapperView'; import type { Lightbox } from '../components/Lightbox'; import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList'; import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog'; -import type { - LinkPreviewResult, - LinkPreviewImage, - LinkPreviewWithDomain, -} from '../types/LinkPreview'; -import * as LinkPreview from '../types/LinkPreview'; -import * as VisualAttachment from '../types/VisualAttachment'; import * as log from '../logging/log'; import type { EmbeddedContactType } from '../types/EmbeddedContact'; import { createConversationView } from '../state/roots/createConversationView'; @@ -100,13 +88,10 @@ import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpir import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing'; import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment'; import { ToastCannotOpenGiftBadge } from '../components/ToastCannotOpenGiftBadge'; -import { autoScale } from '../util/handleImageAttachment'; import { deleteDraftAttachment } from '../util/deleteDraftAttachment'; import { markAllAsApproved } from '../util/markAllAsApproved'; import { markAllAsVerifiedDefault } from '../util/markAllAsVerifiedDefault'; import { retryMessageSend } from '../util/retryMessageSend'; -import { dropNull } from '../util/dropNull'; -import { fileToBytes } from '../util/fileToBytes'; import { isNotNil } from '../util/isNotNil'; import { markViewed } from '../services/MessageUpdater'; import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser'; @@ -121,6 +106,15 @@ import { retryDeleteForEveryone } from '../util/retryDeleteForEveryone'; import { ContactDetail } from '../components/conversation/ContactDetail'; import { MediaGallery } from '../components/conversation/media-gallery/MediaGallery'; import type { ItemClickEvent } from '../components/conversation/media-gallery/types/ItemClickEvent'; +import { + getLinkPreviewForSend, + hasLinkPreviewLoaded, + maybeGrabLinkPreview, + removeLinkPreview, + resetLinkPreview, + suspendLinkPreviews, +} from '../services/LinkPreview'; +import { LinkPreviewSourceType } from '../types/LinkPreview'; import { closeLightbox, isLightboxOpen, @@ -135,7 +129,6 @@ type AttachmentOptions = { type PanelType = { view: Backbone.View; headerTitle?: string }; const FIVE_MINUTES = 1000 * 60 * 5; -const LINK_PREVIEW_TIMEOUT = 60 * 1000; const { Message } = window.Signal.Types; @@ -223,11 +216,6 @@ type MediaType = { const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; export class ConversationView extends window.Backbone.View { - // Debounced functions - private debouncedMaybeGrabLinkPreview: ( - message: string, - caretLocation?: number - ) => void; private debouncedSaveDraft: ( messageText: string, bodyRanges: Array @@ -244,13 +232,6 @@ export class ConversationView extends window.Backbone.View { private quote?: QuotedMessageType; private quotedMessage?: MessageModel; - // Previews - private currentlyMatchedLink?: string; - private disableLinkPreviews?: boolean; - private excludedPreviewUrls: Array = []; - private linkPreviewAbortController?: AbortController; - private preview?: Array; - // Sub-views private contactModalView?: Backbone.View; private conversationView?: Backbone.View; @@ -275,10 +256,6 @@ export class ConversationView extends window.Backbone.View { this.model.throttledGetProfiles || throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES); - this.debouncedMaybeGrabLinkPreview = debounce( - this.maybeGrabLinkPreview.bind(this), - 200 - ); this.debouncedSaveDraft = debounce(this.saveDraft.bind(this), 200); // Events on Conversation model @@ -312,7 +289,7 @@ export class ConversationView extends window.Backbone.View { this.downloadAttachmentWrapper ); this.listenTo(this.model, 'delete-message', this.deleteMessage); - this.listenTo(this.model, 'remove-link-review', this.removeLinkPreview); + this.listenTo(this.model, 'remove-link-review', removeLinkPreview); this.listenTo( this.model, 'remove-all-draft-attachments', @@ -647,8 +624,8 @@ export class ConversationView extends window.Backbone.View { handleClickQuotedMessage: (id: string) => this.scrollToMessage(id), onCloseLinkPreview: () => { - this.disableLinkPreviews = true; - this.removeLinkPreview(); + suspendLinkPreviews(); + removeLinkPreview(); }, openConversation: this.openConversation.bind(this), @@ -1017,7 +994,7 @@ export class ConversationView extends window.Backbone.View { const isRecording = state.audioRecorder.recordingState === RecordingState.Recording; - if (this.preview || isRecording) { + if (hasLinkPreviewLoaded() || isRecording) { return; } @@ -1117,8 +1094,8 @@ export class ConversationView extends window.Backbone.View { window.reduxActions.conversations.setSelectedConversationPanelDepth(0); } - this.removeLinkPreview(); - this.disableLinkPreviews = true; + removeLinkPreview(); + suspendLinkPreviews(); this.remove(); } @@ -1245,7 +1222,7 @@ export class ConversationView extends window.Backbone.View { draftAttachments ); if (this.hasFiles({ includePending: true })) { - this.removeLinkPreview(); + removeLinkPreview(); } } @@ -1354,7 +1331,7 @@ export class ConversationView extends window.Backbone.View { this.forwardMessageModal.remove(); this.forwardMessageModal = undefined; } - this.resetLinkPreview(); + resetLinkPreview(); }, onEditorStateChange: ( messageText: string, @@ -1362,7 +1339,11 @@ export class ConversationView extends window.Backbone.View { caretLocation?: number ) => { if (!attachments.length) { - this.debouncedMaybeGrabLinkPreview(messageText, caretLocation); + maybeGrabLinkPreview( + messageText, + LinkPreviewSourceType.ForwardMessageModal, + caretLocation + ); } }, onTextTooLong: () => showToast(ToastMessageBodyTooLong), @@ -1531,7 +1512,7 @@ export class ConversationView extends window.Backbone.View { ); // Cancel any link still pending, even if it didn't make it into the message - this.resetLinkPreview(); + resetLinkPreview(); return true; } @@ -2920,7 +2901,7 @@ export class ConversationView extends window.Backbone.View { body: message, attachments, quote: this.quote, - preview: this.getLinkPreviewForSend(message), + preview: getLinkPreviewForSend(message), mentions, }, { @@ -2930,7 +2911,7 @@ export class ConversationView extends window.Backbone.View { this.compositionApi.current?.reset(); model.setMarkedUnread(false); this.setQuoteMessage(null); - this.resetLinkPreview(); + resetLinkPreview(); this.clearAttachments(); window.reduxActions.composer.resetComposer(); }, @@ -2953,7 +2934,15 @@ export class ConversationView extends window.Backbone.View { ): void { this.maybeBumpTyping(messageText); this.debouncedSaveDraft(messageText, bodyRanges); - this.debouncedMaybeGrabLinkPreview(messageText, caretLocation); + + // If we have attachments, don't add link preview + if (!this.hasFiles({ includePending: true })) { + maybeGrabLinkPreview( + messageText, + LinkPreviewSourceType.Composer, + caretLocation + ); + } } async saveDraft( @@ -2997,511 +2986,6 @@ export class ConversationView extends window.Backbone.View { } } - maybeGrabLinkPreview(message: string, caretLocation?: number): void { - // Don't generate link previews if user has turned them off - if (!window.Events.getLinkPreviewSetting()) { - return; - } - // Do nothing if we're offline - if (!window.textsecure.messaging) { - return; - } - // If we have attachments, don't add link preview - if (this.hasFiles({ includePending: true })) { - return; - } - // If we're behind a user-configured proxy, we don't support link previews - if (window.isBehindProxy()) { - return; - } - - if (!message) { - this.resetLinkPreview(); - return; - } - if (this.disableLinkPreviews) { - return; - } - - const links = LinkPreview.findLinks(message, caretLocation); - const { currentlyMatchedLink } = this; - if (currentlyMatchedLink && links.includes(currentlyMatchedLink)) { - return; - } - - this.currentlyMatchedLink = undefined; - this.excludedPreviewUrls = this.excludedPreviewUrls || []; - - const link = links.find( - item => - LinkPreview.shouldPreviewHref(item) && - !this.excludedPreviewUrls.includes(item) - ); - if (!link) { - this.removeLinkPreview(); - return; - } - - this.addLinkPreview(link); - } - - resetLinkPreview(): void { - this.disableLinkPreviews = false; - this.excludedPreviewUrls = []; - this.removeLinkPreview(); - } - - removeLinkPreview(): void { - (this.preview || []).forEach((item: LinkPreviewResult) => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - }); - this.preview = undefined; - this.currentlyMatchedLink = undefined; - this.linkPreviewAbortController?.abort(); - this.linkPreviewAbortController = undefined; - - window.reduxActions.linkPreviews.removeLinkPreview(); - } - - async getStickerPackPreview( - url: string, - abortSignal: Readonly - ): Promise { - const isPackDownloaded = ( - pack?: StickerPackDBType - ): pack is StickerPackDBType => { - if (!pack) { - return false; - } - - return pack.status === 'downloaded' || pack.status === 'installed'; - }; - const isPackValid = ( - pack?: StickerPackDBType - ): pack is StickerPackDBType => { - if (!pack) { - return false; - } - return ( - pack.status === 'ephemeral' || - pack.status === 'downloaded' || - pack.status === 'installed' - ); - }; - - const dataFromLink = Stickers.getDataFromLink(url); - if (!dataFromLink) { - return null; - } - const { id, key } = dataFromLink; - - try { - const keyBytes = Bytes.fromHex(key); - const keyBase64 = Bytes.toBase64(keyBytes); - - const existing = Stickers.getStickerPack(id); - if (!isPackDownloaded(existing)) { - await Stickers.downloadEphemeralPack(id, keyBase64); - } - - if (abortSignal.aborted) { - return null; - } - - const pack = Stickers.getStickerPack(id); - - if (!isPackValid(pack)) { - return null; - } - if (pack.key !== keyBase64) { - return null; - } - - const { title, coverStickerId } = pack; - const sticker = pack.stickers[coverStickerId]; - const data = - pack.status === 'ephemeral' - ? await window.Signal.Migrations.readTempData(sticker.path) - : await window.Signal.Migrations.readStickerData(sticker.path); - - if (abortSignal.aborted) { - return null; - } - - let contentType: MIMEType; - const sniffedMimeType = sniffImageMimeType(data); - if (sniffedMimeType) { - contentType = sniffedMimeType; - } else { - log.warn( - 'getStickerPackPreview: Unable to sniff sticker MIME type; falling back to WebP' - ); - contentType = IMAGE_WEBP; - } - - return { - date: null, - description: null, - image: { - ...sticker, - data, - size: data.byteLength, - contentType, - }, - title, - url, - }; - } catch (error) { - log.error( - 'getStickerPackPreview error:', - error && error.stack ? error.stack : error - ); - return null; - } finally { - if (id) { - await Stickers.removeEphemeralPack(id); - } - } - } - - async getGroupPreview( - url: string, - abortSignal: Readonly - ): Promise { - const urlObject = maybeParseUrl(url); - if (!urlObject) { - return null; - } - - const { hash } = urlObject; - if (!hash) { - return null; - } - const groupData = hash.slice(1); - - const { inviteLinkPassword, masterKey } = - window.Signal.Groups.parseGroupLink(groupData); - - const fields = window.Signal.Groups.deriveGroupFields( - Bytes.fromBase64(masterKey) - ); - const id = Bytes.toBase64(fields.id); - const logId = `groupv2(${id})`; - const secretParams = Bytes.toBase64(fields.secretParams); - - log.info(`getGroupPreview/${logId}: Fetching pre-join state`); - const result = await window.Signal.Groups.getPreJoinGroupInfo( - inviteLinkPassword, - masterKey - ); - - if (abortSignal.aborted) { - return null; - } - - const title = - window.Signal.Groups.decryptGroupTitle(result.title, secretParams) || - window.i18n('unknownGroup'); - const description = - result.memberCount === 1 || result.memberCount === undefined - ? window.i18n('GroupV2--join--member-count--single') - : window.i18n('GroupV2--join--member-count--multiple', { - count: result.memberCount.toString(), - }); - let image: undefined | LinkPreviewImage; - - if (result.avatar) { - try { - const data = await window.Signal.Groups.decryptGroupAvatar( - result.avatar, - secretParams - ); - image = { - data, - size: data.byteLength, - contentType: IMAGE_JPEG, - blurHash: await window.imageToBlurHash( - new Blob([data], { - type: IMAGE_JPEG, - }) - ), - }; - } catch (error) { - const errorString = error && error.stack ? error.stack : error; - log.error( - `getGroupPreview/${logId}: Failed to fetch avatar ${errorString}` - ); - } - } - - if (abortSignal.aborted) { - return null; - } - - return { - date: null, - description, - image, - title, - url, - }; - } - - async getPreview( - url: string, - abortSignal: Readonly - ): Promise { - if (LinkPreview.isStickerPack(url)) { - return this.getStickerPackPreview(url, abortSignal); - } - if (LinkPreview.isGroupLink(url)) { - return this.getGroupPreview(url, abortSignal); - } - - const { messaging } = window.textsecure; - if (!messaging) { - throw new Error('messaging is not available!'); - } - - // This is already checked elsewhere, but we want to be extra-careful. - if (!LinkPreview.shouldPreviewHref(url)) { - return null; - } - - const linkPreviewMetadata = await messaging.fetchLinkPreviewMetadata( - url, - abortSignal - ); - if (!linkPreviewMetadata || abortSignal.aborted) { - return null; - } - const { title, imageHref, description, date } = linkPreviewMetadata; - - let image; - if (imageHref && LinkPreview.shouldPreviewHref(imageHref)) { - let objectUrl: void | string; - try { - const fullSizeImage = await messaging.fetchLinkPreviewImage( - imageHref, - abortSignal - ); - if (abortSignal.aborted) { - return null; - } - if (!fullSizeImage) { - throw new Error('Failed to fetch link preview image'); - } - - // Ensure that this file is either small enough or is resized to meet our - // requirements for attachments - const withBlob = await autoScale({ - contentType: fullSizeImage.contentType, - file: new Blob([fullSizeImage.data], { - type: fullSizeImage.contentType, - }), - fileName: title, - }); - - const data = await fileToBytes(withBlob.file); - objectUrl = URL.createObjectURL(withBlob.file); - - const blurHash = await window.imageToBlurHash(withBlob.file); - - const dimensions = await VisualAttachment.getImageDimensions({ - objectUrl, - logger: log, - }); - - image = { - data, - size: data.byteLength, - ...dimensions, - contentType: stringToMIMEType(withBlob.file.type), - blurHash, - }; - } catch (error) { - // We still want to show the preview if we failed to get an image - log.error( - 'getPreview failed to get image for link preview:', - error.message - ); - } finally { - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } - } - } - - if (abortSignal.aborted) { - return null; - } - - return { - date: date || null, - description: description || null, - image, - title, - url, - }; - } - - async addLinkPreview(url: string): Promise { - if (this.currentlyMatchedLink === url) { - log.warn( - 'addLinkPreview should not be called with the same URL like this' - ); - return; - } - - (this.preview || []).forEach((item: LinkPreviewResult) => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - }); - window.reduxActions.linkPreviews.removeLinkPreview(); - this.preview = undefined; - - // Cancel other in-flight link preview requests. - if (this.linkPreviewAbortController) { - log.info( - 'addLinkPreview: canceling another in-flight link preview request' - ); - this.linkPreviewAbortController.abort(); - } - - const thisRequestAbortController = new AbortController(); - this.linkPreviewAbortController = thisRequestAbortController; - - const timeout = setTimeout(() => { - thisRequestAbortController.abort(); - }, LINK_PREVIEW_TIMEOUT); - - this.currentlyMatchedLink = url; - this.renderLinkPreview(); - - try { - const result = await this.getPreview( - url, - thisRequestAbortController.signal - ); - - if (!result) { - log.info( - 'addLinkPreview: failed to load preview (not necessarily a problem)' - ); - - // This helps us disambiguate between two kinds of failure: - // - // 1. We failed to fetch the preview because of (1) a network failure (2) an - // invalid response (3) a timeout - // 2. We failed to fetch the preview because we aborted the request because the - // user changed the link (e.g., by continuing to type the URL) - const failedToFetch = this.currentlyMatchedLink === url; - if (failedToFetch) { - this.excludedPreviewUrls.push(url); - this.removeLinkPreview(); - } - return; - } - - if (result.image && result.image.data) { - const blob = new Blob([result.image.data], { - type: result.image.contentType, - }); - result.image.url = URL.createObjectURL(blob); - } else if (!result.title) { - // A link preview isn't worth showing unless we have either a title or an image - this.removeLinkPreview(); - return; - } - - window.reduxActions.linkPreviews.addLinkPreview({ - ...result, - description: dropNull(result.description), - date: dropNull(result.date), - domain: LinkPreview.getDomain(result.url), - isStickerPack: LinkPreview.isStickerPack(result.url), - }); - this.preview = [result]; - this.renderLinkPreview(); - } catch (error) { - log.error( - 'Problem loading link preview, disabling.', - error && error.stack ? error.stack : error - ); - this.disableLinkPreviews = true; - this.removeLinkPreview(); - } finally { - clearTimeout(timeout); - } - } - - renderLinkPreview(): void { - if (this.forwardMessageModal) { - return; - } - window.reduxActions.composer.setLinkPreviewResult( - Boolean(this.currentlyMatchedLink), - this.getLinkPreviewWithDomain() - ); - } - - getLinkPreviewForSend(message: string): Array { - // Don't generate link previews if user has turned them off - if (!window.storage.get('linkPreviews', false)) { - return []; - } - - if (!this.preview) { - return []; - } - - const urlsInMessage = new Set(LinkPreview.findLinks(message)); - - return ( - this.preview - // This bullet-proofs against sending link previews for URLs that are no longer in - // the message. This can happen if you have a link preview, then quickly delete - // the link and send the message. - .filter(({ url }: Readonly<{ url: string }>) => urlsInMessage.has(url)) - .map((item: LinkPreviewResult) => { - if (item.image) { - // We eliminate the ObjectURL here, unneeded for send or save - return { - ...item, - image: omit(item.image, 'url'), - description: dropNull(item.description), - date: dropNull(item.date), - domain: LinkPreview.getDomain(item.url), - isStickerPack: LinkPreview.isStickerPack(item.url), - }; - } - - return { - ...item, - description: dropNull(item.description), - date: dropNull(item.date), - domain: LinkPreview.getDomain(item.url), - isStickerPack: LinkPreview.isStickerPack(item.url), - }; - }) - ); - } - - getLinkPreviewWithDomain(): LinkPreviewWithDomain | undefined { - if (!this.preview || !this.preview.length) { - return undefined; - } - - const [preview] = this.preview; - return { - ...preview, - domain: LinkPreview.getDomain(preview.url), - }; - } - // Called whenever the user changes the message composition field. But only // fires if there's content in the message field after the change. maybeBumpTyping(messageText: string): void { diff --git a/yarn.lock b/yarn.lock index 6cdc6ed84..a0bf882d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13528,6 +13528,15 @@ react-syntax-highlighter@^15.4.5: prismjs "^1.27.0" refractor "^3.6.0" +react-textarea-autosize@8.3.4: + version "8.3.4" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524" + integrity sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ== + dependencies: + "@babel/runtime" "^7.10.2" + use-composed-ref "^1.3.0" + use-latest "^1.2.1" + react-transition-group@^4.3.0: version "4.4.2" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470" @@ -16135,6 +16144,23 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-composed-ref@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda" + integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ== + +use-isomorphic-layout-effect@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" + integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== + +use-latest@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2" + integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw== + dependencies: + use-isomorphic-layout-effect "^1.1.1" + use@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/use/-/use-3.1.0.tgz#14716bf03fdfefd03040aef58d8b4b85f3a7c544"