Create text stories

This commit is contained in:
Josh Perez 2022-06-16 20:48:57 -04:00 committed by GitHub
parent 973b2264fe
commit d970d427f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 2433 additions and 1106 deletions

View File

@ -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)

View File

@ -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"

View File

@ -0,0 +1 @@
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill="#000"><path d="m9.46429 13.3229-2.95286-10.75147h-3.55857l-2.95286 10.75147h2.84686l.48457-2.1958h2.78628l.48458 2.1958zm-5.088-6.93547c.12114-.53.25742-1.04486.33314-1.68086h.03028c.09086.636.212 1.15086.33315 1.68086l.60571 2.77114h-1.908z"/><path d="m11.0356 13.5046c1.0751 0 1.9383-.5149 2.1654-1.2872 0 .3483.0303.7875.106 1.1055h2.65c-.1211-.53-.1514-.9389-.1514-1.7415v-3.24054c0-1.92314-1.0297-2.77114-3.3466-2.77114-2.1654 0-3.51312.954-3.51312 2.78628h2.74082c0-.75714.1969-1.09028.7269-1.09028.3937 0 .6057.19685.6057.72685v.62086l-1.6203.27257c-.8783.15143-1.58998.43915-1.96855.78743-.424.37857-.68143.92367-.68143 1.58997 0 1.3629.90857 2.2412 2.28658 2.2412zm1.1963-1.7263c-.3937 0-.636-.318-.636-.742 0-.5603.2726-.8934.8631-1.0146l.636-.13627v1.04487c0 .5148-.3483.848-.8631.848z"/></g></svg>

After

Width:  |  Height:  |  Size: 913 B

View File

@ -0,0 +1 @@
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill="#000"><path d="m6.36399 12.9235c-.08628 0-.13903-.0432-.15823-.1294l-.31634-1.6538c0-.0192-.0096-.0336-.02874-.0432-.0096-.0095-.024-.0143-.04314-.0143h-2.34405c-.01917 0-.03835.0048-.05752.0143-.00959.0096-.01438.024-.01438.0432l-.30199 1.6538c-.00959.0862-.06231.1294-.15818.1294h-1.09292c-.04793 0-.08628-.0144-.11505-.0432-.01917-.0287-.02396-.067-.01438-.115l2.1427-9.77873c.01917-.08628.0719-.12942.15818-.12942h1.27987c.09586 0 .14859.04314.15818.12942l2.15708 9.77873v.0288c0 .0862-.04314.1294-.12943.1294zm-2.71793-2.99115c0 .03834.01438.05754.04314.05754h1.91261c.02876 0 .04314-.0192.04314-.05754l-.97788-5.10508c-.00959-.01917-.01917-.02876-.02876-.02876s-.01917.00959-.02876.02876z"/><path d="m13.0346 12.9235c-.0863 0-.139-.0432-.1582-.1294l-.3163-1.6538c0-.0192-.0096-.0336-.0288-.0432-.0096-.0095-.024-.0143-.0431-.0143h-2.3441c-.0191 0-.0383.0048-.0575.0143-.0096.0096-.0144.024-.0144.0432l-.30197 1.6538c-.0096.0862-.06228.1294-.15817.1294h-1.09291c-.04795 0-.08629-.0144-.11503-.0432-.0192-.0287-.024-.067-.0144-.115l2.14268-9.77873c.0192-.08628.0719-.12942.1582-.12942h1.2799c.0958 0 .1485.04314.1581.12942l2.1571 9.77873v.0288c0 .0862-.0431.1294-.1294.1294zm-2.7179-2.99115c0 .03834.0144.05754.0431.05754h1.9127c.0287 0 .0431-.0192.0431-.05754l-.9779-5.10508c-.0096-.01917-.0192-.02876-.0287-.02876-.0096 0-.0192.00959-.0288.02876z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1 @@
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill="#000"><path d="m9.08903 13.5046-3.1824-11.07603h-2.1528l-3.182404 11.07603h1.840804l.7644-2.9328h3.2916l.7644 2.9328zm-4.5552-8.06523c.1092-.4524.2028-.8892.2808-1.404h.0312c.078.5148.1716.9516.2808 1.404l.9828 3.744h-2.5584z"/><path d="m11.4651 13.6918c1.092 0 1.9188-.5928 2.184-1.4976 0 .4056.0312.8892.1092 1.3104h1.6848c-.1248-.5772-.1716-1.2168-.1716-2.1528v-3.15124c0-1.9344-.8424-2.6832-2.8392-2.6832-1.638 0-2.97962.702-3.01082 2.73h1.76282c.0156-.9204.312-1.4976 1.2324-1.4976.7644 0 1.1076.4056 1.1076 1.3884v.5304l-1.4352.2496c-.9516.156-1.6068.4056-2.028.7488-.49922.39004-.82682.96724-.82682 1.77844 0 1.3572.82682 2.2464 2.23082 2.2464zm.6552-1.248c-.702 0-1.0764-.4212-1.0608-1.1544 0-.78.468-1.1856 1.326-1.35724l1.17-.234v1.06084c0 1.0452-.5772 1.6848-1.4352 1.6848z"/></g></svg>

After

Width:  |  Height:  |  Size: 901 B

View File

@ -0,0 +1 @@
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m15.3715 10.1714-.1143.1715c-.4571.8571-.8571 1.4285-1.2571 1.7714-.2286.1714-.4.2857-.5143.4-.1715.0571-.2857.1143-.3429.1143-.1143 0-.1714-.0572-.2286-.1143-.0571-.1143-.0571-.1714-.0571-.2857 0-.1715.0571-.2857.1143-.4572.0571-.1714.1714-.3428.2857-.5143.1143-.1714.2286-.3428.2857-.5142.1143-.1715.1714-.3429.1714-.4572 0-.1714-.0571-.2857-.1714-.39999.0572 0 .1143-.05714.1714-.05714.2286-.17143.3429-.4.3429-.68572 0-.4-.1714-.74285-.5143-.97142-.3428-.22858-.6857-.34286-1.1428-.34286-.4 0-.8572.11428-1.2572.28571-.4.22857-.8.45715-1.1428.8-.34289.34286-.68575.68572-.97146 1.08572-.28572.4-.51429.8-.68572 1.2571-.05714.2286-.11428.4-.17143.5715-.05714.0571-.11428.1143-.11428.1714-.17143.1714-.28572.2857-.45715.3429-.11428.0571-.22857.1142-.28571.1142s-.11429 0-.17143-.0571c-.05714 0-.05714-.0571-.11428-.1714-.05715-.1715-.11429-.4572-.11429-.8572s.05714-.9143.11429-1.42855c.11428-.57143.17142-1.14285.34285-1.77142.11429-.62858.28572-1.2.45715-1.82858.17142-.57142.34285-1.14285.51428-1.59999.17143-.51429.34286-.91429.45714-1.25714.05715-.17143.17143-.28572.22858-.4.05714-.11429.11428-.17143.11428-.17143l.11429-.05714-.11429-.11429c-.17143-.17143-.4-.34286-.57143-.45714-.17143-.11429-.34285-.17143-.51428-.17143s-.34286.05714-.51429.22857c-.17143.11429-.4.28571-.62857.57143l.11429.11428-.11429-.11428c-.34286.34286-.74286.74286-1.14286 1.25714-.4.51429-.85714 1.08572-1.37143 1.71428-.45714.62857-.91428 1.25714-1.37143 1.94286-.17142 0-.34285-.05714-.51428-.05714s-.34286-.05715-.51429-.05715c-.4 0-.8.05714-1.142854.22857-.342858.17143-.571429.45715-.742858.8l-.0571428.17143h.4571428 1.028572c.17143 0 .4 0 .62857.05714-.34285.57143-.68571 1.08568-.97143 1.59998-.342854.5715-.571425 1.0857-.799998 1.5429-.171428.4571-.285714.8-.285714 1.1428 0 .1715.057143.2858.114286.5143.114286.2286.342858.4.628572.5715l.114286.0571.057138-.1714c.45715-.7429.85715-1.6572 1.25715-2.5715.4-.8571.85714-1.71425 1.31428-2.51425.28572.05715.57143.05715.85715.11429.28571.05714.62857.05714.97143.11428-.05715.34286-.11429.68568-.11429.91428-.05714.2857-.05714.5714-.05714.8 0 .7429.11428 1.3143.28571 1.7714.11429.2286.22857.4.45714.5143.17143.1143.4.1715.68572.1715.51428 0 .97143-.2286 1.37143-.5715.05714.1715.17143.3429.34285.5143.28572.2286.57143.3429.91429.3429.4 0 .80005-.1715 1.14285-.4572.2857-.2285.5714-.5143.9143-.9143.0571.2286.1714.4572.2857.6286.2286.2857.5714.4572 1.0286.4572.4 0 .8-.1143 1.2-.4.4-.2286.7428-.5715 1.0857-.9715s.6286-.9143.8571-1.4285l.0572-.1143zm-2.5143-.9714v.22857.11428c-.0571 0-.1143.05715-.1714.11429-.1715.22857-.4.57146-.6857.91426-.2286.3429-.5143.6857-.8 1.0286-.2858.3429-.5143.6286-.8.8-.2858.2286-.45718.2857-.68575.2857-.05714 0-.11429 0-.11429-.0571-.05714-.0572-.05714-.1143-.05714-.2857 0-.2858.05714-.5715.17143-.9143.11429-.3429.28575-.6857.45715-1.0286.2286-.3429.4-.68572.6857-.97143.2286-.28572.5143-.51429.8-.74286.2857-.17143.5143-.28571.7429-.28571.2285 0 .3428.05714.3428.17142.0572.34286.1143.45715.1143.62858zm-5.88571-5.08571c-.11428.28571-.22857.57143-.34285.91428-.17143.57143-.34286 1.14286-.57143 1.71428-.17143.57143-.28572 1.08572-.4 1.65715-.22857-.05715-.51429-.11429-.74286-.17143-.22857-.05715-.45714-.11429-.68571-.17143.4-.62857.74285-1.25714 1.08571-1.77143.4-.62856.8-1.14285 1.14286-1.54285.17143-.22857.34285-.45714.51428-.62857z" fill="#000" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1 @@
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg"><g fill="#000"><path d="m5.41817 2.42857h-1.96032l.33469 1.03594-2.61375 7.74559c-.446252 1.3229-.573749 1.5141-1.035937 1.5938v.6056h2.916557v-.6056c-.78093-.0478-1.11562-.1913-1.11562-.6694 0-.2072.06375-.4781.17531-.8128l.43031-1.3069h3.34687l.39846 1.2113c.12748.3665.2072.6374.2072.8606 0 .4781-.33469.6694-1.08377.7172v.6056h3.88874v-.6056c-.43029-.0797-.62154-.3666-1.03594-1.6575zm-1.17938 2.43844 1.38656 4.27128h-2.78906z"/><path d="m15.1813 11.6564c-.0956.4622-.2391.6694-.4303.6694-.2391 0-.3666-.1753-.3666-.5419v-3.69751c0-1.59377-.765-2.42251-2.1994-2.42251-1.6735 0-3.17157 1.16343-3.17157 2.48623 0 .68531.38251 1.16343.97223 1.16343.58964 0 1.02004-.3984 1.02004-.92435 0-.62154-.6535-.78091-.7651-1.37062.3347-.19126.7332-.31875 1.2432-.31875.7968 0 1.2431.46217 1.2431 1.40252v1.35468l-1.2591.43029c-1.3865.44619-2.03997 1.09969-2.03997 2.11969 0 .9881.68527 1.6256 1.72127 1.6256.749 0 1.3228-.4462 1.6734-1.2431.1753.8128.6216 1.1794 1.3547 1.1794.8606 0 1.3228-.5419 1.4981-1.785zm-3.3628.7171c-.4304 0-.7491-.2868-.7491-.7809 0-.5259.3506-.9084 1.0837-1.1953l.5738-.2072v1.2432.2868c-.255.4463-.5578.6534-.9084.6534z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><g fill="#000"><path d="m17.6981 3.00313c.8745.00091 1.713.34871 2.3313.96709.6184.61837.9662 1.4568.9671 2.33132v11.39706c-.0009.8745-.3487 1.713-.9671 2.3313-.6183.6184-1.4568.9662-2.3313.9671h-11.39705c-.87451-.0009-1.71295-.3487-2.33132-.9671-.61838-.6183-.96618-1.4568-.96709-2.3313v-11.39706c.00091-.87452.34871-1.71295.96709-2.33132.61837-.61838 1.45681-.96618 2.33132-.96709zm0-1.28877h-11.39705c-1.21653.00022-2.38316.48359-3.24338 1.3438-.86021.86021-1.34358 2.02685-1.3438 3.24338v11.39706c.00022 1.2165.48359 2.3832 1.3438 3.2434.86022.8602 2.02685 1.3436 3.24338 1.3438h11.39705c1.2165-.0002 2.3832-.4836 3.2434-1.3438s1.3436-2.0269 1.3438-3.2434v-11.39706c-.0002-1.21653-.4836-2.38317-1.3438-3.24338s-2.0269-1.34358-3.2434-1.3438z"/><path d="m9.94584 8.57129h-1.0357c-.28207 0-.51073.19452-.51073.43447v8.55964c0 .2399.22866.4345.51073.4345h1.0357c.28206 0 .51076-.1946.51076-.4345v-8.55964c0-.23995-.2287-.43447-.51076-.43447z"/><path d="m13.2772 8.57129h-7.69784c-.24123 0-.43678.22436-.43678.50112v1.05489c0 .2768.19555.5011.43678.5011h7.69784c.2413 0 .4368-.2243.4368-.5011v-1.05489c0-.27676-.1955-.50112-.4368-.50112z"/><path d="m17.3106 5.14328c-.1352 0-.2277.09249-.2419.22056-.2561 1.89969-.3202 1.89969-2.2839 2.27679-.1209.02134-.2134.10672-.2134.2419 0 .12807.0925.22057.2134.2348 1.9637.27748 2.0349.34152 2.2839 2.26967.0142.1352.1067.2277.2419.2277.1281 0 .2206-.0925.2419-.2348.2277-1.89971.3344-1.89259 2.2839-2.26257.121-.02135.2135-.10673.2135-.2348 0-.14229-.0925-.22056-.2419-.2419-1.9353-.31306-2.0278-.36287-2.2555-2.26256-.0213-.1423-.1138-.23479-.2419-.23479z"/><path d="m15.672 11.3017c.0264-.097.0793-.1587.1851-.1587.1059 0 .1676.0617.1852.1587.2734 1.4727.2558 1.4903 1.7902 1.799.1058.0176.1675.0793.1675.1852 0 .1058-.0617.1587-.1675.1851-1.5344.3087-1.4815.3351-1.7902 1.7902-.0176.097-.0793.1675-.1852.1675-.1058 0-.1587-.0705-.1851-.1675-.3087-1.4551-.2558-1.4815-1.7902-1.7902-.097-.0264-.1675-.0793-.1675-.1851 0-.1059.0705-.1676.1675-.1852 1.5344-.2999 1.5256-.3263 1.7902-1.799z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m20.9417 3.05829c-.4258-.42616-.9314-.76421-1.4879-.99482s-1.1531-.34927-1.7555-.34918h-11.39658c-1.2166.00023-2.3833.48361-3.24355 1.34388-.86027.86025-1.34365 2.02695-1.34388 3.24355v11.39998c.00114 1.216.48493 2.3818 1.34509 3.2414.86016.8594 2.02634 1.3424 3.24234 1.3426h11.39998c1.216-.0011 2.3818-.4849 3.2414-1.3451.8594-.8601 1.3424-2.0263 1.3426-3.2423v-11.39658c.0001-.60242-.1185-1.19896-.3492-1.75548-.2306-.55654-.5686-1.06216-.9948-1.48795zm-7.6646 7.57031h-2.82v6.9368c-.0108.1257-.0711.242-.1674.3234-.0964.0815-.2211.1214-.34684.1112h-1.02857c-.12575.0102-.25046-.0297-.34679-.1112-.09637-.0814-.15659-.1977-.1675-.3234v-6.9368h-2.82c-.12428-.009-.23995-.0668-.32183-.1607s-.12333-.2164-.11531-.3408v-1.05424c-.00802-.12437.03343-.24686.11531-.34072.08188-.09394.19755-.15171.32183-.16071h7.6971c.1243.009.24.06677.3219.16071.0819.09386.1233.21635.1153.34072v1.05424c.008.1244-.0334.2469-.1153.3408s-.1976.1517-.3219.1607zm4.5558 2.8423c-1.5352.3085-1.482.3351-1.7906 1.7905-.0031.0468-.024.0907-.0584.1226s-.0798.0494-.1268.0489c-.1054 0-.1585-.0703-.1851-.1715-.3086-1.4571-.2571-1.482-1.7906-1.7905-.0968-.0266-.1714-.0798-.1714-.1852-.0005-.047.017-.0923.0489-.1268.0318-.0343.0757-.0552.1225-.0583 1.5352-.3 1.5257-.3266 1.7906-1.8.0059-.0445.0281-.0853.0623-.1145.0342-.029.0779-.0444.1228-.0432.0454-.0024.0899.0126.1243.0421.0346.0296.0563.0713.0609.1164.2734 1.4726.2571 1.4906 1.7906 1.8.0234.0008.0465.0062.0678.016.0214.0097.0406.0236.0565.0409.016.0171.0283.0373.0364.0594.0081.022.0117.0454.0107.0689-.0043.1045-.066.1577-.1714.1843zm2.004-5.35376c-1.95.37029-2.0572.36343-2.2843 2.26286-.0033.0624-.0299.1213-.0748.1648-.0448.0437-.1045.0686-.1669.0701-.0622.0013-.1224-.0218-.1677-.0645-.0452-.0426-.0718-.1014-.0741-.1635-.2494-1.92776-.3205-1.99204-2.2842-2.26976-.0582-.00473-.1124-.03116-.1519-.07404s-.0616-.09906-.0616-.15738c-.0006-.05955.0209-.11721.0603-.16185.0394-.04463.094-.07309.1532-.07987 1.9637-.37714 2.028-.37714 2.2842-2.27657.003-.06223.0304-.12078.0762-.16287.046-.04207.1068-.06426.169-.0617.0625.00143.1221.02643.167.06998.0448.04356.0714.10246.0747.16488.228 1.90028.3206 1.95 2.2551 2.26285.15.02143.2426.09943.2426.24172-.0006.05906-.0231.11579-.0632.15919-.0401.04339-.0948.07036-.1536.07566z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -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",

View File

@ -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);

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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
);
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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';

View File

@ -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 = ({
/>
</div>
)}
{linkPreviewLoading && (
{linkPreviewLoading && linkPreviewResult && (
<div className="preview-wrapper">
<StagedLinkPreview
{...(linkPreviewResult || {})}
{...linkPreviewResult}
i18n={i18n}
onClose={onCloseLinkPreview}
/>

View File

@ -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<T> = {
export type PropsType<T> = {
readonly buttonClassName?: string;
readonly buttonStyle?: CSSProperties;
readonly i18n: LocalizerType;
} & Pick<
ContextMenuPropsType<T>,
@ -139,6 +140,7 @@ export function ContextMenuPopper<T>({
export function ContextMenu<T>({
buttonClassName,
buttonStyle,
i18n,
menuOptions,
popperOptions,
@ -208,24 +210,27 @@ export function ContextMenu<T>({
onClick={handleClick}
onKeyDown={handleKeyDown}
ref={setReferenceElement}
style={buttonStyle}
type="button"
/>
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true,
}}
>
<ContextMenuPopper
focusedIndex={focusedIndex}
isMenuShowing={menuShowing}
menuOptions={menuOptions}
onClose={() => setMenuShowing(false)}
popperOptions={popperOptions}
referenceElement={referenceElement}
title={title}
value={value}
/>
</FocusTrap>
{menuShowing && (
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true,
}}
>
<ContextMenuPopper
focusedIndex={focusedIndex}
isMenuShowing={menuShowing}
menuOptions={menuOptions}
onClose={() => setMenuShowing(false)}
popperOptions={popperOptions}
referenceElement={referenceElement}
title={title}
value={value}
/>
</FocusTrap>
)}
</div>
);
}

View File

@ -322,13 +322,14 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
{linkPreview ? (
<div className="module-ForwardMessageModal--link-preview">
<StagedLinkPreview
date={linkPreview.date || null}
date={linkPreview.date}
description={linkPreview.description || ''}
domain={linkPreview.url}
i18n={i18n}
image={linkPreview.image}
onClose={() => removeLinkPreview()}
title={linkPreview.title}
url={linkPreview.url}
/>
</div>
) : null}

View File

@ -564,7 +564,7 @@ export const MediaEditor = ({
<Slider
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
label={i18n('CustomColorEditor__hue')}
moduleClassName="MediaEditor__hue-slider MediaEditor__tools__tool"
moduleClassName="HueSlider MediaEditor__tools__tool"
onChange={setSliderValue}
value={sliderValue}
/>
@ -623,7 +623,7 @@ export const MediaEditor = ({
<Slider
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
label={i18n('CustomColorEditor__hue')}
moduleClassName="MediaEditor__tools__tool MediaEditor__hue-slider"
moduleClassName="HueSlider MediaEditor__tools__tool"
onChange={setSliderValue}
value={sliderValue}
/>

View File

@ -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: () => <div />,
renderStoryViewer: () => <div />,
showConversation: action('showConversation'),
stories: [
@ -127,7 +130,13 @@ const getDefaultProps = (): PropsType => ({
toggleStoriesView: action('toggleStoriesView'),
});
export const Blank = (): JSX.Element => (
<Stories {...getDefaultProps()} stories={[]} />
);
export const Many = (): JSX.Element => <Stories {...getDefaultProps()} />;
const Template: Story<PropsType> = args => <Stories {...args} />;
export const Blank = Template.bind({});
Blank.args = {
...getDefaultProps(),
stories: [],
};
export const Many = Template.bind({});
Many.args = getDefaultProps();

View File

@ -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<ConversationStoryType>;
@ -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 (
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
{isShowingStoryCreator &&
renderStoryCreator({
onClose: () => setIsShowingStoryCreator(false),
})}
{conversationIdToView &&
renderStoryViewer({
conversationId: conversationIdToView,
@ -110,6 +119,7 @@ export const Stories = ({
<StoriesPane
hiddenStories={hiddenStories}
i18n={i18n}
onAddStory={() => setIsShowingStoryCreator(true)}
onStoryClicked={clickedIdToView => {
const storyIndex = stories.findIndex(
x => x.conversationId === clickedIdToView

View File

@ -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<ConversationStoryType> = {
getFn: (obj, path) => {
@ -53,6 +54,7 @@ function getNewestStory(story: ConversationStoryType): StoryViewType {
export type PropsType = {
hiddenStories: Array<ConversationStoryType>;
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 = ({
<div className="Stories__pane__header--title">
{i18n('Stories__title')}
</div>
<button
aria-label={i18n('Stories__add')}
className="Stories__pane__header--camera"
onClick={onAddStory}
type="button"
/>
</div>
<SearchInput
i18n={i18n}

View File

@ -0,0 +1,50 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryCreator';
import enMessages from '../../_locales/en/messages.json';
import { StoryCreator } from './StoryCreator';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/StoryCreator',
component: StoryCreator,
} as Meta;
const getDefaultProps = (): PropsType => ({
debouncedMaybeGrabLinkPreview: action('debouncedMaybeGrabLinkPreview'),
i18n,
onClose: action('onClose'),
onNext: action('onNext'),
});
const Template: Story<PropsType> = args => <StoryCreator {...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',
};

View File

@ -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<TextAttachmentType, 'color' | 'gradient'> {
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<BackgroundStyleType>(BackgroundStyle.BG1099);
const [textStyle, setTextStyle] = useState<TextStyle>(TextStyle.Regular);
const [textBackground, setTextBackground] = useState<TextBackground>(
TextBackground.None
);
const [sliderValue, setSliderValue] = useState<number>(0);
const [text, setText] = useState<string>('');
const textEditorRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (isEditingText) {
textEditorRef.current?.focus();
} else {
textEditorRef.current?.blur();
}
}, [isEditingText]);
const [isColorPickerShowing, setIsColorPickerShowing] = useState(false);
const [colorPickerPopperButtonRef, setColorPickerPopperButtonRef] =
useState<HTMLButtonElement | null>(null);
const [colorPickerPopperRef, setColorPickerPopperRef] =
useState<HTMLDivElement | null>(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<HTMLButtonElement | null>(null);
const [linkPreviewInputPopperRef, setLinkPreviewInputPopperRef] =
useState<HTMLDivElement | null>(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 (
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="StoryCreator">
<div className="StoryCreator__container">
<TextAttachment
i18n={i18n}
isEditingText={isEditingText}
onChange={setText}
textAttachment={{
...getBackground(selectedBackground),
text,
textStyle,
textForegroundColor,
textBackgroundColor,
preview: hasLinkPreviewApplied ? linkPreview : undefined,
}}
/>
</div>
<div className="StoryCreator__toolbar">
{isEditingText ? (
<div className="StoryCreator__tools">
<Slider
handleStyle={{ backgroundColor: getRGBA(sliderValue) }}
label={i18n('CustomColorEditor__hue')}
moduleClassName="HueSlider StoryCreator__tools__tool"
onChange={setSliderValue}
value={sliderValue}
/>
<ContextMenu
buttonClassName={classNames('StoryCreator__tools__tool', {
'StoryCreator__tools__button--font-regular':
textStyle === TextStyle.Regular,
'StoryCreator__tools__button--font-bold':
textStyle === TextStyle.Bold,
'StoryCreator__tools__button--font-serif':
textStyle === TextStyle.Serif,
'StoryCreator__tools__button--font-script':
textStyle === TextStyle.Script,
'StoryCreator__tools__button--font-condensed':
textStyle === TextStyle.Condensed,
})}
i18n={i18n}
menuOptions={[
{
icon: 'StoryCreator__icon--font-regular',
label: i18n('StoryCreator__text--regular'),
onClick: () => 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}
/>
<button
aria-label={i18n('StoryCreator__text-bg')}
className={classNames('StoryCreator__tools__tool', {
'StoryCreator__tools__button--bg-none':
textBackground === TextBackground.None,
'StoryCreator__tools__button--bg':
textBackground === TextBackground.Background,
'StoryCreator__tools__button--bg-inverse':
textBackground === TextBackground.Inverse,
})}
onClick={() => {
if (textBackground === TextBackground.None) {
setTextBackground(TextBackground.Background);
} else if (textBackground === TextBackground.Background) {
setTextBackground(TextBackground.Inverse);
} else {
setTextBackground(TextBackground.None);
}
}}
type="button"
/>
</div>
) : (
<div className="StoryCreator__toolbar--space" />
)}
<div className="StoryCreator__toolbar--buttons">
<Button
onClick={onClose}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
{i18n('discard')}
</Button>
<div className="StoryCreator__controls">
<button
aria-label={i18n('StoryCreator__story-bg')}
className={classNames({
StoryCreator__control: true,
'StoryCreator__control--bg': true,
'StoryCreator__control--bg--selected': isColorPickerShowing,
})}
onClick={() => setIsColorPickerShowing(!isColorPickerShowing)}
ref={setColorPickerPopperButtonRef}
style={{
background: getBackgroundColor(
getBackground(selectedBackground)
),
}}
type="button"
/>
{isColorPickerShowing && (
<div
className="StoryCreator__popper"
ref={setColorPickerPopperRef}
style={colorPickerPopper.styles.popper}
{...colorPickerPopper.attributes.popper}
>
<div
data-popper-arrow
className="StoryCreator__popper__arrow"
/>
{objectMap<BackgroundStyleType>(
BackgroundStyle,
(bg, backgroundValue) => (
<button
aria-label={i18n('StoryCreator__story-bg')}
className={classNames({
StoryCreator__bg: true,
'StoryCreator__bg--selected':
selectedBackground === backgroundValue,
})}
key={String(bg)}
onClick={() => {
setSelectedBackground(backgroundValue);
setIsColorPickerShowing(false);
}}
type="button"
style={{
background: getBackgroundColor(
getBackground(backgroundValue)
),
}}
/>
)
)}
</div>
)}
<button
aria-label={i18n('StoryCreator__control--draw')}
className={classNames({
StoryCreator__control: true,
'StoryCreator__control--text': true,
'StoryCreator__control--selected': isEditingText,
})}
onClick={() => {
setIsEditingText(!isEditingText);
}}
type="button"
/>
<button
aria-label={i18n('StoryCreator__control--link')}
className="StoryCreator__control StoryCreator__control--link"
onClick={() =>
setIsLinkPreviewInputShowing(!isLinkPreviewInputShowing)
}
ref={setLinkPreviewInputPopperButtonRef}
type="button"
/>
{isLinkPreviewInputShowing && (
<div
className={classNames(
'StoryCreator__popper StoryCreator__link-preview-input-popper',
themeClassName(Theme.Dark)
)}
ref={setLinkPreviewInputPopperRef}
style={linkPreviewInputPopper.styles.popper}
{...linkPreviewInputPopper.attributes.popper}
>
<div
data-popper-arrow
className="StoryCreator__popper__arrow"
/>
<Input
disableSpellcheck
i18n={i18n}
moduleClassName="StoryCreator__link-preview-input"
onChange={setLinkPreviewInputValue}
placeholder={i18n('StoryCreator__link-preview-placeholder')}
ref={el => el?.focus()}
value={linkPreviewInputValue}
/>
<div className="StoryCreator__link-preview-container">
{linkPreview ? (
<>
<StagedLinkPreview
domain={linkPreview.domain}
i18n={i18n}
image={linkPreview.image}
moduleClassName="StoryCreator__link-preview"
title={linkPreview.title}
url={linkPreview.url}
/>
<Button
className="StoryCreator__link-preview-button"
onClick={() => {
setHasLinkPreviewApplied(true);
setIsLinkPreviewInputShowing(false);
}}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{i18n('StoryCreator__add-link')}
</Button>
</>
) : (
<div className="StoryCreator__link-preview-empty">
<div className="StoryCreator__link-preview-empty__icon" />
{i18n('StoryCreator__link-preview-empty')}
</div>
)}
</div>
</div>
)}
</div>
<Button
onClick={onNext}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{i18n('StoryCreator__next')}
</Button>
</div>
</div>
</div>
</FocusTrap>
);
};

View File

@ -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<HTMLDivElement | null>(null);
@ -94,6 +113,20 @@ export const TextAttachment = ({
number | undefined
>();
const textContent = textAttachment.text || '';
const textEditorRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const node = textEditorRef.current;
if (!node) {
return;
}
node.focus();
node.setSelectionRange(node.value.length, node.value.length);
}, [isEditingText]);
return (
<Measure bounds>
{({ contentRect, measureRef }) => (
@ -119,62 +152,72 @@ export const TextAttachment = ({
transform: `scale(${(contentRect.bounds?.height || 1) / 1280})`,
}}
>
{textAttachment.text && (
{(textContent || onChange) && (
<div
className="TextAttachment__text"
style={{
backgroundColor: textAttachment.textBackgroundColor
? getHexFromNumber(textAttachment.textBackgroundColor)
: 'none',
color: getHexFromNumber(
textAttachment.textForegroundColor || COLOR_WHITE_INT
),
font: getFont(
textAttachment.text,
getTextSize(textAttachment.text),
textAttachment.textStyle,
i18n
),
textAlign:
getTextSize(textAttachment.text) === TextSize.Small
? 'left'
: 'center',
: 'transparent',
}}
>
<div className="TextAttachment__text__container">
<Emojify
text={textAttachment.text}
renderNonEmoji={renderNewLines}
{onChange ? (
<TextareaAutosize
className="TextAttachment__text__container TextAttachment__text__textarea"
disabled={!isEditingText}
onChange={ev => onChange(ev.currentTarget.value)}
placeholder={i18n('TextAttachment__placeholder')}
ref={textEditorRef}
style={getTextStyles(
textContent,
textAttachment.textForegroundColor,
textAttachment.textStyle,
i18n
)}
value={textContent}
/>
</div>
) : (
<div
className="TextAttachment__text__container"
style={getTextStyles(
textContent,
textAttachment.textForegroundColor,
textAttachment.textStyle,
i18n
)}
>
<Emojify
text={textContent}
renderNonEmoji={renderNewLines}
/>
</div>
)}
</div>
)}
{textAttachment.preview && (
{textAttachment.preview && textAttachment.preview.url && (
<>
{linkPreviewOffsetTop &&
!isThumbnail &&
textAttachment.preview.url && (
<a
className="TextAttachment__preview__tooltip"
href={textAttachment.preview.url}
rel="noreferrer"
style={{
top: linkPreviewOffsetTop - 150,
}}
target="_blank"
>
<div>
<div>{i18n('TextAttachment__preview__link')}</div>
<div className="TextAttachment__preview__tooltip__url">
{textAttachment.preview.url}
</div>
{linkPreviewOffsetTop && !isThumbnail && (
<a
className="TextAttachment__preview__tooltip"
href={textAttachment.preview.url}
rel="noreferrer"
style={{
top: linkPreviewOffsetTop - 150,
}}
target="_blank"
>
<div>
<div>{i18n('TextAttachment__preview__link')}</div>
<div className="TextAttachment__preview__tooltip__url">
{textAttachment.preview.url}
</div>
<div className="TextAttachment__preview__tooltip__arrow" />
</a>
)}
</div>
<div className="TextAttachment__preview__tooltip__arrow" />
</a>
)}
<div
className={classNames('TextAttachment__preview', {
'TextAttachment__preview--large': Boolean(
className={classNames('TextAttachment__preview-container', {
'TextAttachment__preview-container--large': Boolean(
textAttachment.preview.title
),
})}
@ -186,17 +229,14 @@ export const TextAttachment = ({
setLinkPreviewOffsetTop(linkPreview?.current?.offsetTop)
}
>
<div className="TextAttachment__preview__image" />
<div className="TextAttachment__preview__title">
{textAttachment.preview.title && (
<div className="TextAttachment__preview__title__container">
{textAttachment.preview.title}
</div>
)}
<div className="TextAttachment__preview__url">
{getDomain(String(textAttachment.preview.url))}
</div>
</div>
<StagedLinkPreview
domain={getDomain(String(textAttachment.preview.url))}
i18n={i18n}
image={textAttachment.preview.image}
moduleClassName="TextAttachment__preview"
title={textAttachment.preview.title || undefined}
url={textAttachment.preview.url}
/>
</div>
</>
)}

View File

@ -1192,7 +1192,10 @@ export class Message extends React.PureComponent<Props, State> {
/>
) : null}
<div className="module-message__link-preview__content">
{first.image && previewHasImage && !isFullSizeImage ? (
{first.image &&
first.domain &&
previewHasImage &&
!isFullSizeImage ? (
<div className="module-message__link-preview__icon_container">
<Image
noBorder

View File

@ -1,16 +1,16 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import * as React from 'react';
import { date, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import type { AttachmentType } from '../../types/Attachment';
import { stringToMIMEType } from '../../types/MIME';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import type { Props } from './StagedLinkPreview';
import enMessages from '../../../_locales/en/messages.json';
import { StagedLinkPreview } from './StagedLinkPreview';
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
import { setupI18n } from '../../util/setupI18n';
import { IMAGE_JPEG } from '../../types/MIME';
const LONG_TITLE =
"This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?";
@ -21,150 +21,109 @@ const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/Conversation/StagedLinkPreview',
};
component: StagedLinkPreview,
} as Meta;
const createAttachment = (
props: Partial<AttachmentType> = {}
): 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> = {}): 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<Props> = args => <StagedLinkPreview {...args} />;
return <StagedLinkPreview {...props} />;
export const Loading = Template.bind({});
Loading.args = {
...getDefaultProps(),
domain: '',
};
export const NoImage = (): JSX.Element => {
return <StagedLinkPreview {...createProps()} />;
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 <StagedLinkPreview {...props} />;
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 <StagedLinkPreview {...props} />;
};
ImageNoTitleOrDescription.story = {
name: 'Image, No Title Or Description',
};
export const NoImageLongTitleWithDescription = (): JSX.Element => {
const props = createProps({
title: LONG_TITLE,
});
return <StagedLinkPreview {...props} />;
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 <StagedLinkPreview {...props} />;
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 <StagedLinkPreview {...props} />;
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 <StagedLinkPreview {...props} />;
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 <StagedLinkPreview {...props} />;
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',
};

View File

@ -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<Props> = ({
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 (
<div
className={classNames(
'module-staged-link-preview',
!isLoaded ? 'module-staged-link-preview--is-loading' : null
getClassName(''),
!isLoaded ? getClassName('--is-loading') : null
)}
>
{!isLoaded ? (
<div className="module-staged-link-preview__loading">
<div className={getClassName('__loading')}>
{i18n('loadingPreview')}
</div>
) : null}
{isLoaded && image && isImage && domain ? (
<div className="module-staged-link-preview__icon-container">
<div className={getClassName('__icon-container')}>
<Image
alt={i18n('stagedPreviewThumbnail', [domain])}
attachment={image}
curveBottomLeft={CurveType.Tiny}
curveBottomRight={CurveType.Tiny}
curveTopRight={CurveType.Tiny}
curveTopLeft={CurveType.Tiny}
curveTopRight={CurveType.Tiny}
height={72}
width={72}
url={image.url}
attachment={image}
i18n={i18n}
url={image.url}
width={72}
/>
</div>
) : null}
{isLoaded && !image && <div className={getClassName('__no-image')} />}
{isLoaded ? (
<div className="module-staged-link-preview__content">
<div className="module-staged-link-preview__title">{title}</div>
<div className={getClassName('__content')}>
<div className={getClassName('__title')}>{title}</div>
{description && (
<div className="module-staged-link-preview__description">
<div className={getClassName('__description')}>
{unescape(description)}
</div>
)}
<div className="module-staged-link-preview__footer">
<div className="module-staged-link-preview__location">{domain}</div>
<LinkPreviewDate
date={date}
className="module-message__link-preview__date"
/>
<div className={getClassName('__footer')}>
<div className={getClassName('__location')}>{domain}</div>
<LinkPreviewDate date={date} className={getClassName('__date')} />
</div>
</div>
) : null}
<button
type="button"
className="module-staged-link-preview__close-button"
onClick={onClose}
aria-label={i18n('close')}
/>
{onClose && (
<button
aria-label={i18n('close')}
className={getClassName('__close-button')}
onClick={onClose}
type="button"
/>
)}
</div>
);
};

View File

@ -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})`;
}

View File

@ -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: '',

View File

@ -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<MessageAttributesType> {
let hash;
if (avatarAttachment) {
try {
downloadedAvatar =
await window.Signal.Util.downloadAttachment(
avatarAttachment
);
downloadedAvatar = await downloadAttachment(avatarAttachment);
if (downloadedAvatar) {
const loadedAttachment =

532
ts/services/LinkPreview.ts Normal file
View File

@ -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<string> = [];
let linkPreviewAbortController: AbortController | undefined;
let linkPreviewResult: Array<LinkPreviewResult> | 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<void> {
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<LinkPreviewType> {
// 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<string>(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<AbortSignal>
): Promise<null | LinkPreviewResult> {
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<AbortSignal>
): Promise<null | LinkPreviewResult> {
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<AbortSignal>
): Promise<null | LinkPreviewResult> {
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,
};
}

View File

@ -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<AttachmentDraftType>;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewWithDomain;
linkPreviewResult?: LinkPreviewType;
quotedMessage?: Pick<MessageAttributesType, 'conversationId' | 'quote'>;
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<MessageAttributesType, 'conversationId' | 'quote'>;
};
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,
};
}

View File

@ -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<void, RootStateType, unknown, NoopActionType> {
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,
});
}

View File

@ -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;
};
}
);

View File

@ -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<AttachmentType>;
@ -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,

View File

@ -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 <SmartStoryCreator onClose={onClose} />;
}
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}

View File

@ -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<StateType, LocalizerType>(getIntl);
const linkPreviewForSource = useSelector(getLinkPreview);
return (
<StoryCreator
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
i18n={i18n}
linkPreview={linkPreviewForSource(LinkPreviewSourceType.StoryCreator)}
onClose={onClose}
onNext={noop}
/>
);
}

View File

@ -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;

View File

@ -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);
});

View File

@ -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,

View File

@ -108,7 +108,7 @@ export type ProcessedAttachment = {
caption?: string;
blurHash?: string;
cdnNumber?: number;
textAttachment?: TextAttachmentType;
textAttachment?: Omit<TextAttachmentType, 'preview'>;
};
export type ProcessedGroupContext = {

View File

@ -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;

View File

@ -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 {

View File

@ -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<AttachmentType>;

View File

@ -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<TextAttachmentType, 'color' | 'gradient'>): 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);

View File

@ -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<HTMLInputElement | null>(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<HTMLTextAreaElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2022-06-16T23:23:32.306Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Tooltip.tsx",

10
ts/util/objectMap.ts Normal file
View File

@ -0,0 +1,10 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function objectMap<T>(
obj: Record<string, T>,
f: (key: keyof typeof obj, value: typeof obj[keyof typeof obj]) => unknown
): Array<unknown> {
const keys: Array<keyof typeof obj> = Object.keys(obj);
return keys.map(key => f(key, obj[key]));
}

View File

@ -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<ConversationModel> {
// Debounced functions
private debouncedMaybeGrabLinkPreview: (
message: string,
caretLocation?: number
) => void;
private debouncedSaveDraft: (
messageText: string,
bodyRanges: Array<BodyRangeType>
@ -244,13 +232,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
private quote?: QuotedMessageType;
private quotedMessage?: MessageModel;
// Previews
private currentlyMatchedLink?: string;
private disableLinkPreviews?: boolean;
private excludedPreviewUrls: Array<string> = [];
private linkPreviewAbortController?: AbortController;
private preview?: Array<LinkPreviewResult>;
// Sub-views
private contactModalView?: Backbone.View;
private conversationView?: Backbone.View;
@ -275,10 +256,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
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<ConversationModel> {
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<ConversationModel> {
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<ConversationModel> {
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<ConversationModel> {
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<ConversationModel> {
draftAttachments
);
if (this.hasFiles({ includePending: true })) {
this.removeLinkPreview();
removeLinkPreview();
}
}
@ -1354,7 +1331,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.forwardMessageModal.remove();
this.forwardMessageModal = undefined;
}
this.resetLinkPreview();
resetLinkPreview();
},
onEditorStateChange: (
messageText: string,
@ -1362,7 +1339,11 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
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<ConversationModel> {
);
// 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<ConversationModel> {
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<ConversationModel> {
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<ConversationModel> {
): 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<ConversationModel> {
}
}
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<AbortSignal>
): Promise<null | LinkPreviewResult> {
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<AbortSignal>
): Promise<null | LinkPreviewResult> {
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<AbortSignal>
): Promise<null | LinkPreviewResult> {
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<void> {
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<LinkPreviewType> {
// 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<string>(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 {

View File

@ -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"