Create text stories
|
@ -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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
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
|
## react-virtualized
|
||||||
|
|
||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
|
@ -7229,6 +7229,58 @@
|
||||||
"message": "Error displaying image",
|
"message": "Error displaying image",
|
||||||
"description": "aria-label for image errors"
|
"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": {
|
"TextAttachment__preview__link": {
|
||||||
"message": "Visit link",
|
"message": "Visit link",
|
||||||
"description": "Title for the link preview tooltip"
|
"description": "Title for the link preview tooltip"
|
||||||
|
|
1
images/icons/v2/font-bold.svg
Normal 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 |
1
images/icons/v2/font-condensed.svg
Normal 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 |
1
images/icons/v2/font-regular.svg
Normal 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 |
1
images/icons/v2/font-script.svg
Normal 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 |
1
images/icons/v2/font-serif.svg
Normal 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 |
1
images/icons/v2/text-effect-off-24.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><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 |
1
images/icons/v2/text-effect-on-24.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path 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 |
|
@ -156,6 +156,7 @@
|
||||||
"react-redux": "7.2.8",
|
"react-redux": "7.2.8",
|
||||||
"react-router-dom": "5.0.1",
|
"react-router-dom": "5.0.1",
|
||||||
"react-sortable-hoc": "2.0.0",
|
"react-sortable-hoc": "2.0.0",
|
||||||
|
"react-textarea-autosize": "8.3.4",
|
||||||
"react-virtualized": "9.22.3",
|
"react-virtualized": "9.22.3",
|
||||||
"read-last-lines": "1.8.0",
|
"read-last-lines": "1.8.0",
|
||||||
"redux": "4.1.2",
|
"redux": "4.1.2",
|
||||||
|
|
26
patches/react-textarea-autosize+8.3.4.patch
Normal 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);
|
|
@ -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
|
||||||
|
|
||||||
.module-spinner__container {
|
.module-spinner__container {
|
||||||
|
|
30
stylesheets/components/HueSlider.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
&__icon {
|
||||||
&--draw-pen {
|
&--draw-pen {
|
||||||
@include color-svg('../images/icons/v2/pen-20.svg', $color-white);
|
@include color-svg('../images/icons/v2/pen-20.svg', $color-white);
|
||||||
|
|
146
stylesheets/components/StagedLinkPreview.scss
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
319
stylesheets/components/StoryCreator.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -35,6 +35,26 @@
|
||||||
-webkit-line-clamp: 13;
|
-webkit-line-clamp: 13;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
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;
|
margin-right: 72px;
|
||||||
padding: 34px;
|
padding: 34px;
|
||||||
|
|
||||||
&--large {
|
.TextAttachment__preview-container--large & {
|
||||||
height: 192px;
|
height: 192px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__image {
|
&__no-image {
|
||||||
align-items: center;
|
.TextAttachment__preview-container--large & {
|
||||||
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 & {
|
|
||||||
height: 144px;
|
height: 144px;
|
||||||
width: 144px;
|
width: 144px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&::after {
|
&__content {
|
||||||
@include color-svg('../images/icons/v2/link-24.svg', $color-black);
|
align-items: flex-start;
|
||||||
content: '';
|
display: flex;
|
||||||
height: 44px;
|
flex-direction: column;
|
||||||
width: 44px;
|
justify-content: flex-start;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 422px;
|
||||||
|
|
||||||
|
.TextAttachment__preview-container--large & {
|
||||||
|
max-width: 352px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
align-items: flex-start;
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
color: $color-gray-05;
|
color: $color-gray-05;
|
||||||
display: flex;
|
display: -webkit-box;
|
||||||
flex-direction: column;
|
font: bold 30px Inter;
|
||||||
justify-content: flex-start;
|
overflow: hidden;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__url {
|
&__location {
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
font: bold 30px Inter;
|
font: bold 30px Inter;
|
||||||
max-width: 422px;
|
max-width: 422px;
|
||||||
|
@ -107,7 +111,7 @@
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
.TextAttachment__preview--large & {
|
.TextAttachment__preview-container--large & {
|
||||||
color: $color-white-alpha-60;
|
color: $color-white-alpha-60;
|
||||||
font: 24px Inter;
|
font: 24px Inter;
|
||||||
max-width: 352px;
|
max-width: 352px;
|
||||||
|
|
|
@ -71,6 +71,7 @@
|
||||||
@import './components/GroupDescription.scss';
|
@import './components/GroupDescription.scss';
|
||||||
@import './components/GroupDialog.scss';
|
@import './components/GroupDialog.scss';
|
||||||
@import './components/GroupInput.scss';
|
@import './components/GroupInput.scss';
|
||||||
|
@import './components/HueSlider.scss';
|
||||||
@import './components/Inbox.scss';
|
@import './components/Inbox.scss';
|
||||||
@import './components/IncomingCallBar.scss';
|
@import './components/IncomingCallBar.scss';
|
||||||
@import './components/Input.scss';
|
@import './components/Input.scss';
|
||||||
|
@ -103,7 +104,9 @@
|
||||||
@import './components/SearchResultsLoadingFakeRow.scss';
|
@import './components/SearchResultsLoadingFakeRow.scss';
|
||||||
@import './components/Select.scss';
|
@import './components/Select.scss';
|
||||||
@import './components/Slider.scss';
|
@import './components/Slider.scss';
|
||||||
|
@import './components/StagedLinkPreview.scss';
|
||||||
@import './components/Stories.scss';
|
@import './components/Stories.scss';
|
||||||
|
@import './components/StoryCreator.scss';
|
||||||
@import './components/StoryImage.scss';
|
@import './components/StoryImage.scss';
|
||||||
@import './components/StoryListItem.scss';
|
@import './components/StoryListItem.scss';
|
||||||
@import './components/StoryReplyQuote.scss';
|
@import './components/StoryReplyQuote.scss';
|
||||||
|
|
|
@ -41,7 +41,7 @@ import { AudioCapture } from './conversation/AudioCapture';
|
||||||
import { CompositionUpload } from './CompositionUpload';
|
import { CompositionUpload } from './CompositionUpload';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
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 { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
|
||||||
import { MediaQualitySelector } from './MediaQualitySelector';
|
import { MediaQualitySelector } from './MediaQualitySelector';
|
||||||
|
@ -102,7 +102,7 @@ export type OwnProps = Readonly<{
|
||||||
isSMSOnly?: boolean;
|
isSMSOnly?: boolean;
|
||||||
left?: boolean;
|
left?: boolean;
|
||||||
linkPreviewLoading: boolean;
|
linkPreviewLoading: boolean;
|
||||||
linkPreviewResult?: LinkPreviewWithDomain;
|
linkPreviewResult?: LinkPreviewType;
|
||||||
messageRequestsEnabled?: boolean;
|
messageRequestsEnabled?: boolean;
|
||||||
onClearAttachments(): unknown;
|
onClearAttachments(): unknown;
|
||||||
onClickQuotedMessage(): unknown;
|
onClickQuotedMessage(): unknown;
|
||||||
|
@ -631,10 +631,10 @@ export const CompositionArea = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{linkPreviewLoading && (
|
{linkPreviewLoading && linkPreviewResult && (
|
||||||
<div className="preview-wrapper">
|
<div className="preview-wrapper">
|
||||||
<StagedLinkPreview
|
<StagedLinkPreview
|
||||||
{...(linkPreviewResult || {})}
|
{...linkPreviewResult}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onClose={onCloseLinkPreview}
|
onClose={onCloseLinkPreview}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2018-2022 Signal Messenger, LLC
|
// Copyright 2018-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { KeyboardEvent } from 'react';
|
import type { CSSProperties, KeyboardEvent } from 'react';
|
||||||
import type { Options } from '@popperjs/core';
|
import type { Options } from '@popperjs/core';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
@ -35,6 +35,7 @@ export type ContextMenuPropsType<T> = {
|
||||||
|
|
||||||
export type PropsType<T> = {
|
export type PropsType<T> = {
|
||||||
readonly buttonClassName?: string;
|
readonly buttonClassName?: string;
|
||||||
|
readonly buttonStyle?: CSSProperties;
|
||||||
readonly i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
} & Pick<
|
} & Pick<
|
||||||
ContextMenuPropsType<T>,
|
ContextMenuPropsType<T>,
|
||||||
|
@ -139,6 +140,7 @@ export function ContextMenuPopper<T>({
|
||||||
|
|
||||||
export function ContextMenu<T>({
|
export function ContextMenu<T>({
|
||||||
buttonClassName,
|
buttonClassName,
|
||||||
|
buttonStyle,
|
||||||
i18n,
|
i18n,
|
||||||
menuOptions,
|
menuOptions,
|
||||||
popperOptions,
|
popperOptions,
|
||||||
|
@ -208,24 +210,27 @@ export function ContextMenu<T>({
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
|
style={buttonStyle}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
<FocusTrap
|
{menuShowing && (
|
||||||
focusTrapOptions={{
|
<FocusTrap
|
||||||
allowOutsideClick: true,
|
focusTrapOptions={{
|
||||||
}}
|
allowOutsideClick: true,
|
||||||
>
|
}}
|
||||||
<ContextMenuPopper
|
>
|
||||||
focusedIndex={focusedIndex}
|
<ContextMenuPopper
|
||||||
isMenuShowing={menuShowing}
|
focusedIndex={focusedIndex}
|
||||||
menuOptions={menuOptions}
|
isMenuShowing={menuShowing}
|
||||||
onClose={() => setMenuShowing(false)}
|
menuOptions={menuOptions}
|
||||||
popperOptions={popperOptions}
|
onClose={() => setMenuShowing(false)}
|
||||||
referenceElement={referenceElement}
|
popperOptions={popperOptions}
|
||||||
title={title}
|
referenceElement={referenceElement}
|
||||||
value={value}
|
title={title}
|
||||||
/>
|
value={value}
|
||||||
</FocusTrap>
|
/>
|
||||||
|
</FocusTrap>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -322,13 +322,14 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
||||||
{linkPreview ? (
|
{linkPreview ? (
|
||||||
<div className="module-ForwardMessageModal--link-preview">
|
<div className="module-ForwardMessageModal--link-preview">
|
||||||
<StagedLinkPreview
|
<StagedLinkPreview
|
||||||
date={linkPreview.date || null}
|
date={linkPreview.date}
|
||||||
description={linkPreview.description || ''}
|
description={linkPreview.description || ''}
|
||||||
domain={linkPreview.url}
|
domain={linkPreview.url}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
image={linkPreview.image}
|
image={linkPreview.image}
|
||||||
onClose={() => removeLinkPreview()}
|
onClose={() => removeLinkPreview()}
|
||||||
title={linkPreview.title}
|
title={linkPreview.title}
|
||||||
|
url={linkPreview.url}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
@ -564,7 +564,7 @@ export const MediaEditor = ({
|
||||||
<Slider
|
<Slider
|
||||||
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
|
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
|
||||||
label={i18n('CustomColorEditor__hue')}
|
label={i18n('CustomColorEditor__hue')}
|
||||||
moduleClassName="MediaEditor__hue-slider MediaEditor__tools__tool"
|
moduleClassName="HueSlider MediaEditor__tools__tool"
|
||||||
onChange={setSliderValue}
|
onChange={setSliderValue}
|
||||||
value={sliderValue}
|
value={sliderValue}
|
||||||
/>
|
/>
|
||||||
|
@ -623,7 +623,7 @@ export const MediaEditor = ({
|
||||||
<Slider
|
<Slider
|
||||||
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
|
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
|
||||||
label={i18n('CustomColorEditor__hue')}
|
label={i18n('CustomColorEditor__hue')}
|
||||||
moduleClassName="MediaEditor__tools__tool MediaEditor__hue-slider"
|
moduleClassName="HueSlider MediaEditor__tools__tool"
|
||||||
onChange={setSliderValue}
|
onChange={setSliderValue}
|
||||||
value={sliderValue}
|
value={sliderValue}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright 2022 Signal Messenger, LLC
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Meta, Story } from '@storybook/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
|
@ -22,7 +23,8 @@ const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/Stories',
|
title: 'Components/Stories',
|
||||||
};
|
component: Stories,
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
function createStory({
|
function createStory({
|
||||||
attachment,
|
attachment,
|
||||||
|
@ -83,6 +85,7 @@ const getDefaultProps = (): PropsType => ({
|
||||||
i18n,
|
i18n,
|
||||||
preferredWidthFromStorage: 380,
|
preferredWidthFromStorage: 380,
|
||||||
queueStoryDownload: action('queueStoryDownload'),
|
queueStoryDownload: action('queueStoryDownload'),
|
||||||
|
renderStoryCreator: () => <div />,
|
||||||
renderStoryViewer: () => <div />,
|
renderStoryViewer: () => <div />,
|
||||||
showConversation: action('showConversation'),
|
showConversation: action('showConversation'),
|
||||||
stories: [
|
stories: [
|
||||||
|
@ -127,7 +130,13 @@ const getDefaultProps = (): PropsType => ({
|
||||||
toggleStoriesView: action('toggleStoriesView'),
|
toggleStoriesView: action('toggleStoriesView'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Blank = (): JSX.Element => (
|
const Template: Story<PropsType> = args => <Stories {...args} />;
|
||||||
<Stories {...getDefaultProps()} stories={[]} />
|
|
||||||
);
|
export const Blank = Template.bind({});
|
||||||
export const Many = (): JSX.Element => <Stories {...getDefaultProps()} />;
|
Blank.args = {
|
||||||
|
...getDefaultProps(),
|
||||||
|
stories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Many = Template.bind({});
|
||||||
|
Many.args = getDefaultProps();
|
||||||
|
|
|
@ -6,6 +6,7 @@ import React, { useCallback, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ConversationStoryType } from './StoryListItem';
|
import type { ConversationStoryType } from './StoryListItem';
|
||||||
import type { LocalizerType } from '../types/Util';
|
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 { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
|
||||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||||
import { StoriesPane } from './StoriesPane';
|
import { StoriesPane } from './StoriesPane';
|
||||||
|
@ -18,6 +19,7 @@ export type PropsType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
preferredWidthFromStorage: number;
|
preferredWidthFromStorage: number;
|
||||||
queueStoryDownload: (storyId: string) => unknown;
|
queueStoryDownload: (storyId: string) => unknown;
|
||||||
|
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
|
||||||
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
|
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
|
||||||
showConversation: ShowConversationType;
|
showConversation: ShowConversationType;
|
||||||
stories: Array<ConversationStoryType>;
|
stories: Array<ConversationStoryType>;
|
||||||
|
@ -30,6 +32,7 @@ export const Stories = ({
|
||||||
i18n,
|
i18n,
|
||||||
preferredWidthFromStorage,
|
preferredWidthFromStorage,
|
||||||
queueStoryDownload,
|
queueStoryDownload,
|
||||||
|
renderStoryCreator,
|
||||||
renderStoryViewer,
|
renderStoryViewer,
|
||||||
showConversation,
|
showConversation,
|
||||||
stories,
|
stories,
|
||||||
|
@ -96,8 +99,14 @@ export const Stories = ({
|
||||||
setConversationIdToView(prevStory.conversationId);
|
setConversationIdToView(prevStory.conversationId);
|
||||||
}, [conversationIdToView, stories]);
|
}, [conversationIdToView, stories]);
|
||||||
|
|
||||||
|
const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
|
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
|
||||||
|
{isShowingStoryCreator &&
|
||||||
|
renderStoryCreator({
|
||||||
|
onClose: () => setIsShowingStoryCreator(false),
|
||||||
|
})}
|
||||||
{conversationIdToView &&
|
{conversationIdToView &&
|
||||||
renderStoryViewer({
|
renderStoryViewer({
|
||||||
conversationId: conversationIdToView,
|
conversationId: conversationIdToView,
|
||||||
|
@ -110,6 +119,7 @@ export const Stories = ({
|
||||||
<StoriesPane
|
<StoriesPane
|
||||||
hiddenStories={hiddenStories}
|
hiddenStories={hiddenStories}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
onAddStory={() => setIsShowingStoryCreator(true)}
|
||||||
onStoryClicked={clickedIdToView => {
|
onStoryClicked={clickedIdToView => {
|
||||||
const storyIndex = stories.findIndex(
|
const storyIndex = stories.findIndex(
|
||||||
x => x.conversationId === clickedIdToView
|
x => x.conversationId === clickedIdToView
|
||||||
|
|
|
@ -4,12 +4,13 @@
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { isNotNil } from '../util/isNotNil';
|
|
||||||
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
|
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
import { StoryListItem } from './StoryListItem';
|
import { StoryListItem } from './StoryListItem';
|
||||||
|
import { isNotNil } from '../util/isNotNil';
|
||||||
|
|
||||||
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
|
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
|
||||||
getFn: (obj, path) => {
|
getFn: (obj, path) => {
|
||||||
|
@ -53,6 +54,7 @@ function getNewestStory(story: ConversationStoryType): StoryViewType {
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
hiddenStories: Array<ConversationStoryType>;
|
hiddenStories: Array<ConversationStoryType>;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
onAddStory: () => unknown;
|
||||||
onStoryClicked: (conversationId: string) => unknown;
|
onStoryClicked: (conversationId: string) => unknown;
|
||||||
queueStoryDownload: (storyId: string) => unknown;
|
queueStoryDownload: (storyId: string) => unknown;
|
||||||
showConversation: ShowConversationType;
|
showConversation: ShowConversationType;
|
||||||
|
@ -64,6 +66,7 @@ export type PropsType = {
|
||||||
export const StoriesPane = ({
|
export const StoriesPane = ({
|
||||||
hiddenStories,
|
hiddenStories,
|
||||||
i18n,
|
i18n,
|
||||||
|
onAddStory,
|
||||||
onStoryClicked,
|
onStoryClicked,
|
||||||
queueStoryDownload,
|
queueStoryDownload,
|
||||||
showConversation,
|
showConversation,
|
||||||
|
@ -97,6 +100,12 @@ export const StoriesPane = ({
|
||||||
<div className="Stories__pane__header--title">
|
<div className="Stories__pane__header--title">
|
||||||
{i18n('Stories__title')}
|
{i18n('Stories__title')}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label={i18n('Stories__add')}
|
||||||
|
className="Stories__pane__header--camera"
|
||||||
|
onClick={onAddStory}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
|
50
ts/components/StoryCreator.stories.tsx
Normal 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',
|
||||||
|
};
|
485
ts/components/StoryCreator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -2,18 +2,21 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import Measure from 'react-measure';
|
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 classNames from 'classnames';
|
||||||
|
|
||||||
import type { LocalizerType, RenderTextCallbackType } from '../types/Util';
|
import type { LocalizerType, RenderTextCallbackType } from '../types/Util';
|
||||||
import type { TextAttachmentType } from '../types/Attachment';
|
import type { TextAttachmentType } from '../types/Attachment';
|
||||||
import { AddNewLines } from './conversation/AddNewLines';
|
import { AddNewLines } from './conversation/AddNewLines';
|
||||||
import { Emojify } from './conversation/Emojify';
|
import { Emojify } from './conversation/Emojify';
|
||||||
|
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||||
import { TextAttachmentStyleType } from '../types/Attachment';
|
import { TextAttachmentStyleType } from '../types/Attachment';
|
||||||
import { count } from '../util/grapheme';
|
import { count } from '../util/grapheme';
|
||||||
import { getDomain } from '../types/LinkPreview';
|
import { getDomain } from '../types/LinkPreview';
|
||||||
import { getFontNameByTextScript } from '../util/getFontNameByTextScript';
|
import { getFontNameByTextScript } from '../util/getFontNameByTextScript';
|
||||||
import {
|
import {
|
||||||
|
COLOR_WHITE_INT,
|
||||||
getHexFromNumber,
|
getHexFromNumber,
|
||||||
getBackgroundColor,
|
getBackgroundColor,
|
||||||
} from '../util/getStoryBackground';
|
} from '../util/getStoryBackground';
|
||||||
|
@ -27,7 +30,6 @@ const renderNewLines: RenderTextCallbackType = ({
|
||||||
|
|
||||||
const CHAR_LIMIT_TEXT_LARGE = 50;
|
const CHAR_LIMIT_TEXT_LARGE = 50;
|
||||||
const CHAR_LIMIT_TEXT_MEDIUM = 200;
|
const CHAR_LIMIT_TEXT_MEDIUM = 200;
|
||||||
const COLOR_WHITE_INT = 4294704123;
|
|
||||||
const FONT_SIZE_LARGE = 64;
|
const FONT_SIZE_LARGE = 64;
|
||||||
const FONT_SIZE_MEDIUM = 42;
|
const FONT_SIZE_MEDIUM = 42;
|
||||||
const FONT_SIZE_SMALL = 32;
|
const FONT_SIZE_SMALL = 32;
|
||||||
|
@ -40,7 +42,9 @@ enum TextSize {
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
isEditingText?: boolean;
|
||||||
isThumbnail?: boolean;
|
isThumbnail?: boolean;
|
||||||
|
onChange?: (text: string) => unknown;
|
||||||
textAttachment: TextAttachmentType;
|
textAttachment: TextAttachmentType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -84,9 +88,24 @@ function getFont(
|
||||||
return `${fontWeight}${fontSize}pt ${fontName}`;
|
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 = ({
|
export const TextAttachment = ({
|
||||||
i18n,
|
i18n,
|
||||||
|
isEditingText,
|
||||||
isThumbnail,
|
isThumbnail,
|
||||||
|
onChange,
|
||||||
textAttachment,
|
textAttachment,
|
||||||
}: PropsType): JSX.Element | null => {
|
}: PropsType): JSX.Element | null => {
|
||||||
const linkPreview = useRef<HTMLDivElement | null>(null);
|
const linkPreview = useRef<HTMLDivElement | null>(null);
|
||||||
|
@ -94,6 +113,20 @@ export const TextAttachment = ({
|
||||||
number | undefined
|
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 (
|
return (
|
||||||
<Measure bounds>
|
<Measure bounds>
|
||||||
{({ contentRect, measureRef }) => (
|
{({ contentRect, measureRef }) => (
|
||||||
|
@ -119,62 +152,72 @@ export const TextAttachment = ({
|
||||||
transform: `scale(${(contentRect.bounds?.height || 1) / 1280})`,
|
transform: `scale(${(contentRect.bounds?.height || 1) / 1280})`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{textAttachment.text && (
|
{(textContent || onChange) && (
|
||||||
<div
|
<div
|
||||||
className="TextAttachment__text"
|
className="TextAttachment__text"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: textAttachment.textBackgroundColor
|
backgroundColor: textAttachment.textBackgroundColor
|
||||||
? getHexFromNumber(textAttachment.textBackgroundColor)
|
? getHexFromNumber(textAttachment.textBackgroundColor)
|
||||||
: 'none',
|
: 'transparent',
|
||||||
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',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="TextAttachment__text__container">
|
{onChange ? (
|
||||||
<Emojify
|
<TextareaAutosize
|
||||||
text={textAttachment.text}
|
className="TextAttachment__text__container TextAttachment__text__textarea"
|
||||||
renderNonEmoji={renderNewLines}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{textAttachment.preview && (
|
{textAttachment.preview && textAttachment.preview.url && (
|
||||||
<>
|
<>
|
||||||
{linkPreviewOffsetTop &&
|
{linkPreviewOffsetTop && !isThumbnail && (
|
||||||
!isThumbnail &&
|
<a
|
||||||
textAttachment.preview.url && (
|
className="TextAttachment__preview__tooltip"
|
||||||
<a
|
href={textAttachment.preview.url}
|
||||||
className="TextAttachment__preview__tooltip"
|
rel="noreferrer"
|
||||||
href={textAttachment.preview.url}
|
style={{
|
||||||
rel="noreferrer"
|
top: linkPreviewOffsetTop - 150,
|
||||||
style={{
|
}}
|
||||||
top: linkPreviewOffsetTop - 150,
|
target="_blank"
|
||||||
}}
|
>
|
||||||
target="_blank"
|
<div>
|
||||||
>
|
<div>{i18n('TextAttachment__preview__link')}</div>
|
||||||
<div>
|
<div className="TextAttachment__preview__tooltip__url">
|
||||||
<div>{i18n('TextAttachment__preview__link')}</div>
|
{textAttachment.preview.url}
|
||||||
<div className="TextAttachment__preview__tooltip__url">
|
|
||||||
{textAttachment.preview.url}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="TextAttachment__preview__tooltip__arrow" />
|
</div>
|
||||||
</a>
|
<div className="TextAttachment__preview__tooltip__arrow" />
|
||||||
)}
|
</a>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={classNames('TextAttachment__preview', {
|
className={classNames('TextAttachment__preview-container', {
|
||||||
'TextAttachment__preview--large': Boolean(
|
'TextAttachment__preview-container--large': Boolean(
|
||||||
textAttachment.preview.title
|
textAttachment.preview.title
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
|
@ -186,17 +229,14 @@ export const TextAttachment = ({
|
||||||
setLinkPreviewOffsetTop(linkPreview?.current?.offsetTop)
|
setLinkPreviewOffsetTop(linkPreview?.current?.offsetTop)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="TextAttachment__preview__image" />
|
<StagedLinkPreview
|
||||||
<div className="TextAttachment__preview__title">
|
domain={getDomain(String(textAttachment.preview.url))}
|
||||||
{textAttachment.preview.title && (
|
i18n={i18n}
|
||||||
<div className="TextAttachment__preview__title__container">
|
image={textAttachment.preview.image}
|
||||||
{textAttachment.preview.title}
|
moduleClassName="TextAttachment__preview"
|
||||||
</div>
|
title={textAttachment.preview.title || undefined}
|
||||||
)}
|
url={textAttachment.preview.url}
|
||||||
<div className="TextAttachment__preview__url">
|
/>
|
||||||
{getDomain(String(textAttachment.preview.url))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1192,7 +1192,10 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="module-message__link-preview__content">
|
<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">
|
<div className="module-message__link-preview__icon_container">
|
||||||
<Image
|
<Image
|
||||||
noBorder
|
noBorder
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
// Copyright 2020-2021 Signal Messenger, LLC
|
// Copyright 2020-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Meta, Story } from '@storybook/react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { date, text } from '@storybook/addon-knobs';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
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 type { Props } from './StagedLinkPreview';
|
||||||
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
import { StagedLinkPreview } from './StagedLinkPreview';
|
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 =
|
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?";
|
"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 {
|
export default {
|
||||||
title: 'Components/Conversation/StagedLinkPreview',
|
title: 'Components/Conversation/StagedLinkPreview',
|
||||||
};
|
component: StagedLinkPreview,
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
const createAttachment = (
|
const getDefaultProps = (): Props => ({
|
||||||
props: Partial<AttachmentType> = {}
|
date: Date.now(),
|
||||||
): AttachmentType => ({
|
description: 'This is a description',
|
||||||
contentType: stringToMIMEType(
|
domain: 'signal.org',
|
||||||
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,
|
|
||||||
i18n,
|
i18n,
|
||||||
onClose: action('onClose'),
|
onClose: action('onClose'),
|
||||||
|
title: 'This is a super-sweet site',
|
||||||
|
url: 'https://www.signal.org',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Loading = (): JSX.Element => {
|
const Template: Story<Props> = args => <StagedLinkPreview {...args} />;
|
||||||
const props = createProps({ domain: '' });
|
|
||||||
|
|
||||||
return <StagedLinkPreview {...props} />;
|
export const Loading = Template.bind({});
|
||||||
|
Loading.args = {
|
||||||
|
...getDefaultProps(),
|
||||||
|
domain: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoImage = (): JSX.Element => {
|
export const NoImage = Template.bind({});
|
||||||
return <StagedLinkPreview {...createProps()} />;
|
|
||||||
|
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 => {
|
export const ImageNoTitleOrDescription = Template.bind({});
|
||||||
const props = createProps({
|
ImageNoTitleOrDescription.args = {
|
||||||
image: createAttachment({
|
...getDefaultProps(),
|
||||||
url: '/fixtures/kitten-4-112-112.jpg',
|
title: '',
|
||||||
contentType: stringToMIMEType('image/jpeg'),
|
description: '',
|
||||||
}),
|
domain: 'instagram.com',
|
||||||
});
|
image: fakeAttachment({
|
||||||
|
url: '/fixtures/kitten-4-112-112.jpg',
|
||||||
return <StagedLinkPreview {...props} />;
|
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 = {
|
ImageNoTitleOrDescription.story = {
|
||||||
name: 'Image, No Title Or Description',
|
name: 'Image, No Title Or Description',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoImageLongTitleWithDescription = (): JSX.Element => {
|
export const NoImageLongTitleWithDescription = Template.bind({});
|
||||||
const props = createProps({
|
NoImageLongTitleWithDescription.args = {
|
||||||
title: LONG_TITLE,
|
...getDefaultProps(),
|
||||||
});
|
title: LONG_TITLE,
|
||||||
|
|
||||||
return <StagedLinkPreview {...props} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
NoImageLongTitleWithDescription.story = {
|
NoImageLongTitleWithDescription.story = {
|
||||||
name: 'No Image, Long Title With Description',
|
name: 'No Image, Long Title With Description',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoImageLongTitleWithoutDescription = (): JSX.Element => {
|
export const NoImageLongTitleWithoutDescription = Template.bind({});
|
||||||
const props = createProps({
|
NoImageLongTitleWithoutDescription.args = {
|
||||||
title: LONG_TITLE,
|
...getDefaultProps(),
|
||||||
description: '',
|
title: LONG_TITLE,
|
||||||
});
|
description: '',
|
||||||
|
|
||||||
return <StagedLinkPreview {...props} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
NoImageLongTitleWithoutDescription.story = {
|
NoImageLongTitleWithoutDescription.story = {
|
||||||
name: 'No Image, Long Title Without Description',
|
name: 'No Image, Long Title Without Description',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImageLongTitleWithoutDescription = (): JSX.Element => {
|
export const ImageLongTitleWithoutDescription = Template.bind({});
|
||||||
const props = createProps({
|
ImageLongTitleWithoutDescription.args = {
|
||||||
title: LONG_TITLE,
|
...getDefaultProps(),
|
||||||
image: createAttachment({
|
title: LONG_TITLE,
|
||||||
url: '/fixtures/kitten-4-112-112.jpg',
|
image: fakeAttachment({
|
||||||
contentType: stringToMIMEType('image/jpeg'),
|
url: '/fixtures/kitten-4-112-112.jpg',
|
||||||
}),
|
contentType: IMAGE_JPEG,
|
||||||
});
|
}),
|
||||||
|
|
||||||
return <StagedLinkPreview {...props} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ImageLongTitleWithoutDescription.story = {
|
ImageLongTitleWithoutDescription.story = {
|
||||||
name: 'Image, Long Title Without Description',
|
name: 'Image, Long Title Without Description',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImageLongTitleAndDescription = (): JSX.Element => {
|
export const ImageLongTitleAndDescription = Template.bind({});
|
||||||
const props = createProps({
|
ImageLongTitleAndDescription.args = {
|
||||||
title: LONG_TITLE,
|
...getDefaultProps(),
|
||||||
description: LONG_DESCRIPTION,
|
title: LONG_TITLE,
|
||||||
image: createAttachment({
|
description: LONG_DESCRIPTION,
|
||||||
url: '/fixtures/kitten-4-112-112.jpg',
|
image: fakeAttachment({
|
||||||
contentType: stringToMIMEType('image/jpeg'),
|
url: '/fixtures/kitten-4-112-112.jpg',
|
||||||
}),
|
contentType: IMAGE_JPEG,
|
||||||
});
|
}),
|
||||||
|
|
||||||
return <StagedLinkPreview {...props} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ImageLongTitleAndDescription.story = {
|
ImageLongTitleAndDescription.story = {
|
||||||
name: 'Image, Long Title And Description',
|
name: 'Image, Long Title And Description',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EverythingImageTitleDescriptionAndDate = (): JSX.Element => {
|
export const EverythingImageTitleDescriptionAndDate = Template.bind({});
|
||||||
const props = createProps({
|
EverythingImageTitleDescriptionAndDate.args = {
|
||||||
title: LONG_TITLE,
|
...getDefaultProps(),
|
||||||
description: LONG_DESCRIPTION,
|
title: LONG_TITLE,
|
||||||
date: Date.now(),
|
description: LONG_DESCRIPTION,
|
||||||
image: createAttachment({
|
image: fakeAttachment({
|
||||||
url: '/fixtures/kitten-4-112-112.jpg',
|
url: '/fixtures/kitten-4-112-112.jpg',
|
||||||
contentType: stringToMIMEType('image/jpeg'),
|
contentType: IMAGE_JPEG,
|
||||||
}),
|
}),
|
||||||
});
|
|
||||||
|
|
||||||
return <StagedLinkPreview {...props} />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
EverythingImageTitleDescriptionAndDate.story = {
|
EverythingImageTitleDescriptionAndDate.story = {
|
||||||
name: 'Everything: image, title, description, and date',
|
name: 'Everything: image, title, description, and date',
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,84 +8,86 @@ import { unescape } from 'lodash';
|
||||||
import { CurveType, Image } from './Image';
|
import { CurveType, Image } from './Image';
|
||||||
import { LinkPreviewDate } from './LinkPreviewDate';
|
import { LinkPreviewDate } from './LinkPreviewDate';
|
||||||
|
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||||
import { isImageAttachment } from '../../types/Attachment';
|
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
|
import { getClassNamesFor } from '../../util/getClassNamesFor';
|
||||||
|
import { isImageAttachment } from '../../types/Attachment';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = LinkPreviewType & {
|
||||||
title?: string;
|
|
||||||
description?: null | string;
|
|
||||||
date?: null | number;
|
|
||||||
domain?: string;
|
|
||||||
image?: AttachmentType;
|
|
||||||
|
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
moduleClassName?: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StagedLinkPreview: React.FC<Props> = ({
|
export const StagedLinkPreview: React.FC<Props> = ({
|
||||||
onClose,
|
|
||||||
i18n,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
image,
|
|
||||||
date,
|
date,
|
||||||
|
description,
|
||||||
domain,
|
domain,
|
||||||
|
i18n,
|
||||||
|
image,
|
||||||
|
moduleClassName,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const isImage = isImageAttachment(image);
|
const isImage = isImageAttachment(image);
|
||||||
const isLoaded = Boolean(domain);
|
const isLoaded = Boolean(domain);
|
||||||
|
|
||||||
|
const getClassName = getClassNamesFor(
|
||||||
|
'module-staged-link-preview',
|
||||||
|
moduleClassName
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-staged-link-preview',
|
getClassName(''),
|
||||||
!isLoaded ? 'module-staged-link-preview--is-loading' : null
|
!isLoaded ? getClassName('--is-loading') : null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isLoaded ? (
|
{!isLoaded ? (
|
||||||
<div className="module-staged-link-preview__loading">
|
<div className={getClassName('__loading')}>
|
||||||
{i18n('loadingPreview')}
|
{i18n('loadingPreview')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isLoaded && image && isImage && domain ? (
|
{isLoaded && image && isImage && domain ? (
|
||||||
<div className="module-staged-link-preview__icon-container">
|
<div className={getClassName('__icon-container')}>
|
||||||
<Image
|
<Image
|
||||||
alt={i18n('stagedPreviewThumbnail', [domain])}
|
alt={i18n('stagedPreviewThumbnail', [domain])}
|
||||||
|
attachment={image}
|
||||||
curveBottomLeft={CurveType.Tiny}
|
curveBottomLeft={CurveType.Tiny}
|
||||||
curveBottomRight={CurveType.Tiny}
|
curveBottomRight={CurveType.Tiny}
|
||||||
curveTopRight={CurveType.Tiny}
|
|
||||||
curveTopLeft={CurveType.Tiny}
|
curveTopLeft={CurveType.Tiny}
|
||||||
|
curveTopRight={CurveType.Tiny}
|
||||||
height={72}
|
height={72}
|
||||||
width={72}
|
|
||||||
url={image.url}
|
|
||||||
attachment={image}
|
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
url={image.url}
|
||||||
|
width={72}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isLoaded && !image && <div className={getClassName('__no-image')} />}
|
||||||
{isLoaded ? (
|
{isLoaded ? (
|
||||||
<div className="module-staged-link-preview__content">
|
<div className={getClassName('__content')}>
|
||||||
<div className="module-staged-link-preview__title">{title}</div>
|
<div className={getClassName('__title')}>{title}</div>
|
||||||
{description && (
|
{description && (
|
||||||
<div className="module-staged-link-preview__description">
|
<div className={getClassName('__description')}>
|
||||||
{unescape(description)}
|
{unescape(description)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="module-staged-link-preview__footer">
|
<div className={getClassName('__footer')}>
|
||||||
<div className="module-staged-link-preview__location">{domain}</div>
|
<div className={getClassName('__location')}>{domain}</div>
|
||||||
<LinkPreviewDate
|
<LinkPreviewDate date={date} className={getClassName('__date')} />
|
||||||
date={date}
|
|
||||||
className="module-message__link-preview__date"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
{onClose && (
|
||||||
type="button"
|
<button
|
||||||
className="module-staged-link-preview__close-button"
|
aria-label={i18n('close')}
|
||||||
onClick={onClose}
|
className={getClassName('__close-button')}
|
||||||
aria-label={i18n('close')}
|
onClick={onClose}
|
||||||
/>
|
type="button"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,29 +5,33 @@ function getRatio(min: number, max: number, value: number) {
|
||||||
return (value - min) / (max - min);
|
return (value - min) / (max - min);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_BLACK = 7;
|
||||||
|
const MIN_WHITE = 95;
|
||||||
|
|
||||||
function getHSLValues(percentage: number): [number, number, number] {
|
function getHSLValues(percentage: number): [number, number, number] {
|
||||||
if (percentage <= 10) {
|
if (percentage <= MAX_BLACK) {
|
||||||
return [0, 0, 1 - getRatio(0, 10, percentage)];
|
return [0, 0.5, 0.5 * getRatio(0, MAX_BLACK, percentage)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (percentage < 20) {
|
if (percentage >= MIN_WHITE) {
|
||||||
return [0, 0.5, 0.5 * getRatio(10, 20, percentage)];
|
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];
|
return [338 * ratio, 1, 0.5];
|
||||||
}
|
|
||||||
|
|
||||||
export function getHSL(percentage: number): string {
|
|
||||||
const [h, s, l] = getHSLValues(percentage);
|
|
||||||
return `hsl(${h}, ${s * 100}%, ${l * 100}%)`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative
|
// https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative
|
||||||
export function getRGBA(percentage: number, alpha = 1): string {
|
function hslToRGB(
|
||||||
const [h, s, l] = getHSLValues(percentage);
|
h: number,
|
||||||
|
s: number,
|
||||||
|
l: number
|
||||||
|
): {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
} {
|
||||||
const a = s * Math.min(l, 1 - l);
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
|
||||||
function f(n: number): number {
|
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);
|
return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rgbValue = [
|
return {
|
||||||
Math.round(255 * f(0)),
|
r: Math.round(255 * f(0)),
|
||||||
Math.round(255 * f(8)),
|
g: Math.round(255 * f(8)),
|
||||||
Math.round(255 * f(4)),
|
b: Math.round(255 * f(4)),
|
||||||
]
|
};
|
||||||
.map(String)
|
}
|
||||||
.join(',');
|
|
||||||
|
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})`;
|
return `rgba(${rgbValue},${alpha})`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,13 +26,13 @@ export function getTextStyleAttributes(
|
||||||
return { fill: color, strokeWidth: 0, textBackgroundColor: '' };
|
return { fill: color, strokeWidth: 0, textBackgroundColor: '' };
|
||||||
case TextStyle.Highlight:
|
case TextStyle.Highlight:
|
||||||
return {
|
return {
|
||||||
fill: hueSliderValue <= 5 ? '#000' : '#fff',
|
fill: hueSliderValue >= 95 ? '#000' : '#fff',
|
||||||
strokeWidth: 0,
|
strokeWidth: 0,
|
||||||
textBackgroundColor: color,
|
textBackgroundColor: color,
|
||||||
};
|
};
|
||||||
case TextStyle.Outline:
|
case TextStyle.Outline:
|
||||||
return {
|
return {
|
||||||
fill: hueSliderValue <= 5 ? '#000' : '#fff',
|
fill: hueSliderValue >= 95 ? '#000' : '#fff',
|
||||||
stroke: color,
|
stroke: color,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
textBackgroundColor: '',
|
textBackgroundColor: '',
|
||||||
|
|
|
@ -157,6 +157,7 @@ import { SeenStatus } from '../MessageSeenStatus';
|
||||||
import { isNewReactionReplacingPrevious } from '../reactions/util';
|
import { isNewReactionReplacingPrevious } from '../reactions/util';
|
||||||
import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
|
import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
|
||||||
import { GiftBadgeStates } from '../components/conversation/Message';
|
import { GiftBadgeStates } from '../components/conversation/Message';
|
||||||
|
import { downloadAttachment } from '../util/downloadAttachment';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
|
||||||
|
@ -2451,10 +2452,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
let hash;
|
let hash;
|
||||||
if (avatarAttachment) {
|
if (avatarAttachment) {
|
||||||
try {
|
try {
|
||||||
downloadedAvatar =
|
downloadedAvatar = await downloadAttachment(avatarAttachment);
|
||||||
await window.Signal.Util.downloadAttachment(
|
|
||||||
avatarAttachment
|
|
||||||
);
|
|
||||||
|
|
||||||
if (downloadedAvatar) {
|
if (downloadedAvatar) {
|
||||||
const loadedAttachment =
|
const loadedAttachment =
|
||||||
|
|
532
ts/services/LinkPreview.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -11,23 +11,30 @@ import type {
|
||||||
InMemoryAttachmentDraftType,
|
InMemoryAttachmentDraftType,
|
||||||
} from '../../types/Attachment';
|
} from '../../types/Attachment';
|
||||||
import type { MessageAttributesType } from '../../model-types.d';
|
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 { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnecessaryAllocation';
|
||||||
import type { RemoveLinkPreviewActionType } from './linkPreviews';
|
import type {
|
||||||
import { REMOVE_PREVIEW as REMOVE_LINK_PREVIEW } from './linkPreviews';
|
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 { writeDraftAttachment } from '../../util/writeDraftAttachment';
|
||||||
import { deleteDraftAttachment } from '../../util/deleteDraftAttachment';
|
import { deleteDraftAttachment } from '../../util/deleteDraftAttachment';
|
||||||
import { replaceIndex } from '../../util/replaceIndex';
|
import { replaceIndex } from '../../util/replaceIndex';
|
||||||
import { resolveDraftAttachmentOnDisk } from '../../util/resolveDraftAttachmentOnDisk';
|
import { resolveDraftAttachmentOnDisk } from '../../util/resolveDraftAttachmentOnDisk';
|
||||||
import type { HandleAttachmentsProcessingArgsType } from '../../util/handleAttachmentsProcessing';
|
import type { HandleAttachmentsProcessingArgsType } from '../../util/handleAttachmentsProcessing';
|
||||||
import { handleAttachmentsProcessing } from '../../util/handleAttachmentsProcessing';
|
import { handleAttachmentsProcessing } from '../../util/handleAttachmentsProcessing';
|
||||||
|
import { LinkPreviewSourceType } from '../../types/LinkPreview';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
export type ComposerStateType = {
|
export type ComposerStateType = {
|
||||||
attachments: ReadonlyArray<AttachmentDraftType>;
|
attachments: ReadonlyArray<AttachmentDraftType>;
|
||||||
linkPreviewLoading: boolean;
|
linkPreviewLoading: boolean;
|
||||||
linkPreviewResult?: LinkPreviewWithDomain;
|
linkPreviewResult?: LinkPreviewType;
|
||||||
quotedMessage?: Pick<MessageAttributesType, 'conversationId' | 'quote'>;
|
quotedMessage?: Pick<MessageAttributesType, 'conversationId' | 'quote'>;
|
||||||
shouldSendHighQualityAttachments: boolean;
|
shouldSendHighQualityAttachments: boolean;
|
||||||
};
|
};
|
||||||
|
@ -38,7 +45,6 @@ const ADD_PENDING_ATTACHMENT = 'composer/ADD_PENDING_ATTACHMENT';
|
||||||
const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS';
|
const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS';
|
||||||
const RESET_COMPOSER = 'composer/RESET_COMPOSER';
|
const RESET_COMPOSER = 'composer/RESET_COMPOSER';
|
||||||
const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING';
|
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';
|
const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE';
|
||||||
|
|
||||||
type AddPendingAttachmentActionType = {
|
type AddPendingAttachmentActionType = {
|
||||||
|
@ -60,26 +66,18 @@ type SetHighQualitySettingActionType = {
|
||||||
payload: boolean;
|
payload: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SetLinkPreviewResultActionType = {
|
|
||||||
type: typeof SET_LINK_PREVIEW_RESULT;
|
|
||||||
payload: {
|
|
||||||
isLoading: boolean;
|
|
||||||
linkPreview?: LinkPreviewWithDomain;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type SetQuotedMessageActionType = {
|
type SetQuotedMessageActionType = {
|
||||||
type: typeof SET_QUOTED_MESSAGE;
|
type: typeof SET_QUOTED_MESSAGE;
|
||||||
payload?: Pick<MessageAttributesType, 'conversationId' | 'quote'>;
|
payload?: Pick<MessageAttributesType, 'conversationId' | 'quote'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ComposerActionType =
|
type ComposerActionType =
|
||||||
|
| AddLinkPreviewActionType
|
||||||
| AddPendingAttachmentActionType
|
| AddPendingAttachmentActionType
|
||||||
| RemoveLinkPreviewActionType
|
| RemoveLinkPreviewActionType
|
||||||
| ReplaceAttachmentsActionType
|
| ReplaceAttachmentsActionType
|
||||||
| ResetComposerActionType
|
| ResetComposerActionType
|
||||||
| SetHighQualitySettingActionType
|
| SetHighQualitySettingActionType
|
||||||
| SetLinkPreviewResultActionType
|
|
||||||
| SetQuotedMessageActionType;
|
| SetQuotedMessageActionType;
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
@ -91,7 +89,6 @@ export const actions = {
|
||||||
removeAttachment,
|
removeAttachment,
|
||||||
replaceAttachments,
|
replaceAttachments,
|
||||||
resetComposer,
|
resetComposer,
|
||||||
setLinkPreviewResult,
|
|
||||||
setMediaQualitySetting,
|
setMediaQualitySetting,
|
||||||
setQuotedMessage,
|
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(
|
function setMediaQualitySetting(
|
||||||
payload: boolean
|
payload: boolean
|
||||||
): SetHighQualitySettingActionType {
|
): 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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
linkPreviewLoading: action.payload.isLoading,
|
linkPreviewLoading: true,
|
||||||
linkPreviewResult: action.payload.linkPreview,
|
linkPreviewResult: action.payload.linkPreview,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,34 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||||
|
import type { LinkPreviewSourceType } from '../../types/LinkPreview';
|
||||||
import { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnecessaryAllocation';
|
import { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnecessaryAllocation';
|
||||||
|
import { maybeGrabLinkPreview } from '../../services/LinkPreview';
|
||||||
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
export type LinkPreviewsStateType = {
|
export type LinkPreviewsStateType = {
|
||||||
readonly linkPreview?: LinkPreviewType;
|
readonly linkPreview?: LinkPreviewType;
|
||||||
|
readonly source?: LinkPreviewSourceType;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
const ADD_PREVIEW = 'linkPreviews/ADD_PREVIEW';
|
export const ADD_PREVIEW = 'linkPreviews/ADD_PREVIEW';
|
||||||
export const REMOVE_PREVIEW = 'linkPreviews/REMOVE_PREVIEW';
|
export const REMOVE_PREVIEW = 'linkPreviews/REMOVE_PREVIEW';
|
||||||
|
|
||||||
type AddLinkPreviewActionType = {
|
export type AddLinkPreviewActionType = {
|
||||||
type: 'linkPreviews/ADD_PREVIEW';
|
type: 'linkPreviews/ADD_PREVIEW';
|
||||||
payload: LinkPreviewType;
|
payload: {
|
||||||
|
linkPreview: LinkPreviewType;
|
||||||
|
source: LinkPreviewSourceType;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RemoveLinkPreviewActionType = {
|
export type RemoveLinkPreviewActionType = {
|
||||||
|
@ -30,15 +41,30 @@ type LinkPreviewsActionType =
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
export const actions = {
|
function debouncedMaybeGrabLinkPreview(
|
||||||
addLinkPreview,
|
message: string,
|
||||||
removeLinkPreview,
|
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 {
|
return {
|
||||||
type: ADD_PREVIEW,
|
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
|
// Reducer
|
||||||
|
|
||||||
export function getEmptyState(): LinkPreviewsStateType {
|
export function getEmptyState(): LinkPreviewsStateType {
|
||||||
|
@ -64,13 +99,15 @@ export function reducer(
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
linkPreview: payload,
|
linkPreview: payload.linkPreview,
|
||||||
|
source: payload.source,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === REMOVE_PREVIEW) {
|
if (action.type === REMOVE_PREVIEW) {
|
||||||
return assignWithNoUnnecessaryAllocation(state, {
|
return assignWithNoUnnecessaryAllocation(state, {
|
||||||
linkPreview: undefined,
|
linkPreview: undefined,
|
||||||
|
source: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,12 +6,21 @@ import { createSelector } from 'reselect';
|
||||||
import { assert } from '../../util/assert';
|
import { assert } from '../../util/assert';
|
||||||
import { getDomain } from '../../types/LinkPreview';
|
import { getDomain } from '../../types/LinkPreview';
|
||||||
|
|
||||||
|
import type { LinkPreviewSourceType } from '../../types/LinkPreview';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
|
|
||||||
export const getLinkPreview = createSelector(
|
export const getLinkPreview = createSelector(
|
||||||
({ linkPreviews }: StateType) => linkPreviews.linkPreview,
|
({ linkPreviews }: StateType) => linkPreviews,
|
||||||
linkPreview => {
|
({ linkPreview, source }) => {
|
||||||
if (linkPreview) {
|
return (fromSource: LinkPreviewSourceType) => {
|
||||||
|
if (!linkPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source !== fromSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const domain = getDomain(linkPreview.url);
|
const domain = getDomain(linkPreview.url);
|
||||||
assert(domain !== undefined, "Domain of linkPreview can't be undefined");
|
assert(domain !== undefined, "Domain of linkPreview can't be undefined");
|
||||||
|
|
||||||
|
@ -20,8 +29,6 @@ export const getLinkPreview = createSelector(
|
||||||
domain,
|
domain,
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,19 +2,20 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
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 { 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 = {
|
export type SmartForwardMessageModalProps = {
|
||||||
attachments?: Array<AttachmentType>;
|
attachments?: Array<AttachmentType>;
|
||||||
|
@ -54,7 +55,7 @@ const mapStateToProps = (
|
||||||
const candidateConversations = getAllComposableConversations(state);
|
const candidateConversations = getAllComposableConversations(state);
|
||||||
const recentEmojis = selectRecentEmojis(state);
|
const recentEmojis = selectRecentEmojis(state);
|
||||||
const skinTone = getEmojiSkinTone(state);
|
const skinTone = getEmojiSkinTone(state);
|
||||||
const linkPreview = getLinkPreview(state);
|
const linkPreviewForSource = getLinkPreview(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attachments,
|
attachments,
|
||||||
|
@ -64,7 +65,9 @@ const mapStateToProps = (
|
||||||
hasContact,
|
hasContact,
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
isSticker,
|
isSticker,
|
||||||
linkPreview,
|
linkPreview: linkPreviewForSource(
|
||||||
|
LinkPreviewSourceType.ForwardMessageModal
|
||||||
|
),
|
||||||
messageBody,
|
messageBody,
|
||||||
onClose,
|
onClose,
|
||||||
onEditorStateChange,
|
onEditorStateChange,
|
||||||
|
|
|
@ -6,7 +6,9 @@ import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
|
import type { PropsType as SmartStoryCreatorPropsType } from './StoryCreator';
|
||||||
import type { PropsType as SmartStoryViewerPropsType } from './StoryViewer';
|
import type { PropsType as SmartStoryViewerPropsType } from './StoryViewer';
|
||||||
|
import { SmartStoryCreator } from './StoryCreator';
|
||||||
import { SmartStoryViewer } from './StoryViewer';
|
import { SmartStoryViewer } from './StoryViewer';
|
||||||
import { Stories } from '../../components/Stories';
|
import { Stories } from '../../components/Stories';
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
|
@ -15,6 +17,12 @@ import { getStories } from '../selectors/stories';
|
||||||
import { useStoriesActions } from '../ducks/stories';
|
import { useStoriesActions } from '../ducks/stories';
|
||||||
import { useConversationsActions } from '../ducks/conversations';
|
import { useConversationsActions } from '../ducks/conversations';
|
||||||
|
|
||||||
|
function renderStoryCreator({
|
||||||
|
onClose,
|
||||||
|
}: SmartStoryCreatorPropsType): JSX.Element {
|
||||||
|
return <SmartStoryCreator onClose={onClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
function renderStoryViewer({
|
function renderStoryViewer({
|
||||||
conversationId,
|
conversationId,
|
||||||
onClose,
|
onClose,
|
||||||
|
@ -56,6 +64,7 @@ export function SmartStories(): JSX.Element | null {
|
||||||
hiddenStories={hiddenStories}
|
hiddenStories={hiddenStories}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
preferredWidthFromStorage={preferredWidthFromStorage}
|
preferredWidthFromStorage={preferredWidthFromStorage}
|
||||||
|
renderStoryCreator={renderStoryCreator}
|
||||||
renderStoryViewer={renderStoryViewer}
|
renderStoryViewer={renderStoryViewer}
|
||||||
showConversation={showConversation}
|
showConversation={showConversation}
|
||||||
stories={stories}
|
stories={stories}
|
||||||
|
|
35
ts/state/smart/StoryCreator.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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', () => {
|
describe('setMediaQualitySetting', () => {
|
||||||
it('toggles the media quality setting', () => {
|
it('toggles the media quality setting', () => {
|
||||||
const { setMediaQualitySetting } = actions;
|
const { setMediaQualitySetting } = actions;
|
||||||
|
|
|
@ -26,7 +26,7 @@ describe('both/state/ducks/linkPreviews', () => {
|
||||||
it('updates linkPreview', () => {
|
it('updates linkPreview', () => {
|
||||||
const state = getEmptyState();
|
const state = getEmptyState();
|
||||||
const linkPreview = getMockLinkPreview();
|
const linkPreview = getMockLinkPreview();
|
||||||
const nextState = reducer(state, addLinkPreview(linkPreview));
|
const nextState = reducer(state, addLinkPreview(linkPreview, 0));
|
||||||
|
|
||||||
assert.strictEqual(nextState.linkPreview, linkPreview);
|
assert.strictEqual(nextState.linkPreview, linkPreview);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1806,6 +1806,7 @@ export default class MessageReceiver
|
||||||
throw new Error('Text attachments must have text!');
|
throw new Error('Text attachments must have text!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO DESKTOP-3714 we should download the story link preview image
|
||||||
attachments.push({
|
attachments.push({
|
||||||
size: text.length,
|
size: text.length,
|
||||||
contentType: APPLICATION_OCTET_STREAM,
|
contentType: APPLICATION_OCTET_STREAM,
|
||||||
|
|
2
ts/textsecure/Types.d.ts
vendored
|
@ -108,7 +108,7 @@ export type ProcessedAttachment = {
|
||||||
caption?: string;
|
caption?: string;
|
||||||
blurHash?: string;
|
blurHash?: string;
|
||||||
cdnNumber?: number;
|
cdnNumber?: number;
|
||||||
textAttachment?: TextAttachmentType;
|
textAttachment?: Omit<TextAttachmentType, 'preview'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProcessedGroupContext = {
|
export type ProcessedGroupContext = {
|
||||||
|
|
|
@ -102,8 +102,9 @@ export type TextAttachmentType = {
|
||||||
textForegroundColor?: number | null;
|
textForegroundColor?: number | null;
|
||||||
textBackgroundColor?: number | null;
|
textBackgroundColor?: number | null;
|
||||||
preview?: {
|
preview?: {
|
||||||
url?: string | null;
|
image?: AttachmentType;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
url?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
gradient?: {
|
gradient?: {
|
||||||
startColor?: number | null;
|
startColor?: number | null;
|
||||||
|
|
|
@ -26,6 +26,12 @@ export type LinkPreviewWithDomain = {
|
||||||
domain: string;
|
domain: string;
|
||||||
} & LinkPreviewResult;
|
} & LinkPreviewResult;
|
||||||
|
|
||||||
|
export enum LinkPreviewSourceType {
|
||||||
|
Composer,
|
||||||
|
ForwardMessageModal,
|
||||||
|
StoryCreator,
|
||||||
|
}
|
||||||
|
|
||||||
const linkify = LinkifyIt();
|
const linkify = LinkifyIt();
|
||||||
|
|
||||||
export function shouldPreviewHref(href: string): boolean {
|
export function shouldPreviewHref(href: string): boolean {
|
||||||
|
|
|
@ -4,9 +4,9 @@
|
||||||
import type { AttachmentType } from '../Attachment';
|
import type { AttachmentType } from '../Attachment';
|
||||||
|
|
||||||
export type LinkPreviewType = {
|
export type LinkPreviewType = {
|
||||||
title: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
domain: string;
|
domain?: string;
|
||||||
url: string;
|
url: string;
|
||||||
isStickerPack?: boolean;
|
isStickerPack?: boolean;
|
||||||
image?: Readonly<AttachmentType>;
|
image?: Readonly<AttachmentType>;
|
||||||
|
|
|
@ -4,7 +4,8 @@
|
||||||
import type { AttachmentType, TextAttachmentType } from '../types/Attachment';
|
import type { AttachmentType, TextAttachmentType } from '../types/Attachment';
|
||||||
|
|
||||||
const COLOR_BLACK_ALPHA_90 = 'rgba(0, 0, 0, 0.9)';
|
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 {
|
export function getHexFromNumber(color: number): string {
|
||||||
return `#${color.toString(16).slice(2)}`;
|
return `#${color.toString(16).slice(2)}`;
|
||||||
|
@ -13,11 +14,11 @@ export function getHexFromNumber(color: number): string {
|
||||||
export function getBackgroundColor({
|
export function getBackgroundColor({
|
||||||
color,
|
color,
|
||||||
gradient,
|
gradient,
|
||||||
}: TextAttachmentType): string {
|
}: Pick<TextAttachmentType, 'color' | 'gradient'>): string {
|
||||||
if (gradient) {
|
if (gradient) {
|
||||||
return `linear-gradient(${gradient.angle}deg, ${getHexFromNumber(
|
return `linear-gradient(${gradient.angle}deg, ${getHexFromNumber(
|
||||||
gradient.startColor || COLOR_WHITE_INT
|
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);
|
return getHexFromNumber(color || COLOR_WHITE_INT);
|
||||||
|
|
|
@ -912,7 +912,7 @@
|
||||||
"rule": "jQuery-load(",
|
"rule": "jQuery-load(",
|
||||||
"path": "node_modules/agent-base/node_modules/debug/src/common.js",
|
"path": "node_modules/agent-base/node_modules/debug/src/common.js",
|
||||||
"line": "\tcreateDebug.enable(createDebug.load());",
|
"line": "\tcreateDebug.enable(createDebug.load());",
|
||||||
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2022-02-11T21:58:24.827Z"
|
"updated": "2022-02-11T21:58:24.827Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -7351,6 +7351,125 @@
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2022-06-04T00:50:49.405Z"
|
"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(",
|
"rule": "jQuery-wrap(",
|
||||||
"path": "node_modules/redux/node_modules/regenerator-runtime/runtime.js",
|
"path": "node_modules/redux/node_modules/regenerator-runtime/runtime.js",
|
||||||
|
@ -8108,6 +8227,41 @@
|
||||||
"updated": "2020-08-26T00:10:28.628Z",
|
"updated": "2020-08-26T00:10:28.628Z",
|
||||||
"reasonDetail": "isn't jquery"
|
"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",
|
"rule": "eval",
|
||||||
"path": "node_modules/vm2/lib/nodevm.js",
|
"path": "node_modules/vm2/lib/nodevm.js",
|
||||||
|
@ -8751,6 +8905,13 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-11-30T10:15:33.662Z"
|
"updated": "2021-11-30T10:15:33.662Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/StoryCreator.tsx",
|
||||||
|
"line": " const textEditorRef = useRef<HTMLInputElement | null>(null);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2022-06-16T23:23:32.306Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/StoryImage.tsx",
|
"path": "ts/components/StoryImage.tsx",
|
||||||
|
@ -8779,6 +8940,13 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2022-04-06T00:59:17.194Z"
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/Tooltip.tsx",
|
"path": "ts/components/Tooltip.tsx",
|
||||||
|
|
10
ts/util/objectMap.ts
Normal 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]));
|
||||||
|
}
|
|
@ -6,18 +6,15 @@
|
||||||
import type * as Backbone from 'backbone';
|
import type * as Backbone from 'backbone';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import * as React 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 { render } from 'mustache';
|
||||||
|
|
||||||
import type { AttachmentType } from '../types/Attachment';
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
import { isGIF } from '../types/Attachment';
|
import { isGIF } from '../types/Attachment';
|
||||||
import * as Attachment from '../types/Attachment';
|
import * as Attachment from '../types/Attachment';
|
||||||
import type { StickerPackType as StickerPackDBType } from '../sql/Interface';
|
|
||||||
import * as Stickers from '../types/Stickers';
|
import * as Stickers from '../types/Stickers';
|
||||||
import type { BodyRangeType, BodyRangesType } from '../types/Util';
|
import type { BodyRangeType, BodyRangesType } from '../types/Util';
|
||||||
import type { MIMEType } from '../types/MIME';
|
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 { ConversationModel } from '../models/conversations';
|
||||||
import type {
|
import type {
|
||||||
GroupV2PendingMemberType,
|
GroupV2PendingMemberType,
|
||||||
|
@ -31,7 +28,6 @@ import type { MessageModel } from '../models/messages';
|
||||||
import { getMessageById } from '../messages/getMessageById';
|
import { getMessageById } from '../messages/getMessageById';
|
||||||
import { getContactId } from '../messages/helpers';
|
import { getContactId } from '../messages/helpers';
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
import { maybeParseUrl } from '../util/url';
|
|
||||||
import { enqueueReactionForSend } from '../reactions/enqueueReactionForSend';
|
import { enqueueReactionForSend } from '../reactions/enqueueReactionForSend';
|
||||||
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
|
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
|
||||||
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
|
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
|
||||||
|
@ -42,7 +38,6 @@ import {
|
||||||
isGroupV1,
|
isGroupV1,
|
||||||
} from '../util/whatTypeOfConversation';
|
} from '../util/whatTypeOfConversation';
|
||||||
import { findAndFormatContact } from '../util/findAndFormatContact';
|
import { findAndFormatContact } from '../util/findAndFormatContact';
|
||||||
import * as Bytes from '../Bytes';
|
|
||||||
import { getPreferredBadgeSelector } from '../state/selectors/badges';
|
import { getPreferredBadgeSelector } from '../state/selectors/badges';
|
||||||
import {
|
import {
|
||||||
canReply,
|
canReply,
|
||||||
|
@ -61,13 +56,6 @@ import { ReactWrapperView } from './ReactWrapperView';
|
||||||
import type { Lightbox } from '../components/Lightbox';
|
import type { Lightbox } from '../components/Lightbox';
|
||||||
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
|
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
|
||||||
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
|
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 * as log from '../logging/log';
|
||||||
import type { EmbeddedContactType } from '../types/EmbeddedContact';
|
import type { EmbeddedContactType } from '../types/EmbeddedContact';
|
||||||
import { createConversationView } from '../state/roots/createConversationView';
|
import { createConversationView } from '../state/roots/createConversationView';
|
||||||
|
@ -100,13 +88,10 @@ import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpir
|
||||||
import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing';
|
import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing';
|
||||||
import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment';
|
import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment';
|
||||||
import { ToastCannotOpenGiftBadge } from '../components/ToastCannotOpenGiftBadge';
|
import { ToastCannotOpenGiftBadge } from '../components/ToastCannotOpenGiftBadge';
|
||||||
import { autoScale } from '../util/handleImageAttachment';
|
|
||||||
import { deleteDraftAttachment } from '../util/deleteDraftAttachment';
|
import { deleteDraftAttachment } from '../util/deleteDraftAttachment';
|
||||||
import { markAllAsApproved } from '../util/markAllAsApproved';
|
import { markAllAsApproved } from '../util/markAllAsApproved';
|
||||||
import { markAllAsVerifiedDefault } from '../util/markAllAsVerifiedDefault';
|
import { markAllAsVerifiedDefault } from '../util/markAllAsVerifiedDefault';
|
||||||
import { retryMessageSend } from '../util/retryMessageSend';
|
import { retryMessageSend } from '../util/retryMessageSend';
|
||||||
import { dropNull } from '../util/dropNull';
|
|
||||||
import { fileToBytes } from '../util/fileToBytes';
|
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import { markViewed } from '../services/MessageUpdater';
|
import { markViewed } from '../services/MessageUpdater';
|
||||||
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
|
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
|
||||||
|
@ -121,6 +106,15 @@ import { retryDeleteForEveryone } from '../util/retryDeleteForEveryone';
|
||||||
import { ContactDetail } from '../components/conversation/ContactDetail';
|
import { ContactDetail } from '../components/conversation/ContactDetail';
|
||||||
import { MediaGallery } from '../components/conversation/media-gallery/MediaGallery';
|
import { MediaGallery } from '../components/conversation/media-gallery/MediaGallery';
|
||||||
import type { ItemClickEvent } from '../components/conversation/media-gallery/types/ItemClickEvent';
|
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 {
|
import {
|
||||||
closeLightbox,
|
closeLightbox,
|
||||||
isLightboxOpen,
|
isLightboxOpen,
|
||||||
|
@ -135,7 +129,6 @@ type AttachmentOptions = {
|
||||||
type PanelType = { view: Backbone.View; headerTitle?: string };
|
type PanelType = { view: Backbone.View; headerTitle?: string };
|
||||||
|
|
||||||
const FIVE_MINUTES = 1000 * 60 * 5;
|
const FIVE_MINUTES = 1000 * 60 * 5;
|
||||||
const LINK_PREVIEW_TIMEOUT = 60 * 1000;
|
|
||||||
|
|
||||||
const { Message } = window.Signal.Types;
|
const { Message } = window.Signal.Types;
|
||||||
|
|
||||||
|
@ -223,11 +216,6 @@ type MediaType = {
|
||||||
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
|
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
|
||||||
|
|
||||||
export class ConversationView extends window.Backbone.View<ConversationModel> {
|
export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
// Debounced functions
|
|
||||||
private debouncedMaybeGrabLinkPreview: (
|
|
||||||
message: string,
|
|
||||||
caretLocation?: number
|
|
||||||
) => void;
|
|
||||||
private debouncedSaveDraft: (
|
private debouncedSaveDraft: (
|
||||||
messageText: string,
|
messageText: string,
|
||||||
bodyRanges: Array<BodyRangeType>
|
bodyRanges: Array<BodyRangeType>
|
||||||
|
@ -244,13 +232,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
private quote?: QuotedMessageType;
|
private quote?: QuotedMessageType;
|
||||||
private quotedMessage?: MessageModel;
|
private quotedMessage?: MessageModel;
|
||||||
|
|
||||||
// Previews
|
|
||||||
private currentlyMatchedLink?: string;
|
|
||||||
private disableLinkPreviews?: boolean;
|
|
||||||
private excludedPreviewUrls: Array<string> = [];
|
|
||||||
private linkPreviewAbortController?: AbortController;
|
|
||||||
private preview?: Array<LinkPreviewResult>;
|
|
||||||
|
|
||||||
// Sub-views
|
// Sub-views
|
||||||
private contactModalView?: Backbone.View;
|
private contactModalView?: Backbone.View;
|
||||||
private conversationView?: Backbone.View;
|
private conversationView?: Backbone.View;
|
||||||
|
@ -275,10 +256,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
this.model.throttledGetProfiles ||
|
this.model.throttledGetProfiles ||
|
||||||
throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES);
|
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);
|
this.debouncedSaveDraft = debounce(this.saveDraft.bind(this), 200);
|
||||||
|
|
||||||
// Events on Conversation model
|
// Events on Conversation model
|
||||||
|
@ -312,7 +289,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
this.downloadAttachmentWrapper
|
this.downloadAttachmentWrapper
|
||||||
);
|
);
|
||||||
this.listenTo(this.model, 'delete-message', this.deleteMessage);
|
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.listenTo(
|
||||||
this.model,
|
this.model,
|
||||||
'remove-all-draft-attachments',
|
'remove-all-draft-attachments',
|
||||||
|
@ -647,8 +624,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
handleClickQuotedMessage: (id: string) => this.scrollToMessage(id),
|
handleClickQuotedMessage: (id: string) => this.scrollToMessage(id),
|
||||||
|
|
||||||
onCloseLinkPreview: () => {
|
onCloseLinkPreview: () => {
|
||||||
this.disableLinkPreviews = true;
|
suspendLinkPreviews();
|
||||||
this.removeLinkPreview();
|
removeLinkPreview();
|
||||||
},
|
},
|
||||||
|
|
||||||
openConversation: this.openConversation.bind(this),
|
openConversation: this.openConversation.bind(this),
|
||||||
|
@ -1017,7 +994,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
const isRecording =
|
const isRecording =
|
||||||
state.audioRecorder.recordingState === RecordingState.Recording;
|
state.audioRecorder.recordingState === RecordingState.Recording;
|
||||||
|
|
||||||
if (this.preview || isRecording) {
|
if (hasLinkPreviewLoaded() || isRecording) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1117,8 +1094,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
window.reduxActions.conversations.setSelectedConversationPanelDepth(0);
|
window.reduxActions.conversations.setSelectedConversationPanelDepth(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.removeLinkPreview();
|
removeLinkPreview();
|
||||||
this.disableLinkPreviews = true;
|
suspendLinkPreviews();
|
||||||
|
|
||||||
this.remove();
|
this.remove();
|
||||||
}
|
}
|
||||||
|
@ -1245,7 +1222,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
draftAttachments
|
draftAttachments
|
||||||
);
|
);
|
||||||
if (this.hasFiles({ includePending: true })) {
|
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.remove();
|
||||||
this.forwardMessageModal = undefined;
|
this.forwardMessageModal = undefined;
|
||||||
}
|
}
|
||||||
this.resetLinkPreview();
|
resetLinkPreview();
|
||||||
},
|
},
|
||||||
onEditorStateChange: (
|
onEditorStateChange: (
|
||||||
messageText: string,
|
messageText: string,
|
||||||
|
@ -1362,7 +1339,11 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
caretLocation?: number
|
caretLocation?: number
|
||||||
) => {
|
) => {
|
||||||
if (!attachments.length) {
|
if (!attachments.length) {
|
||||||
this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
|
maybeGrabLinkPreview(
|
||||||
|
messageText,
|
||||||
|
LinkPreviewSourceType.ForwardMessageModal,
|
||||||
|
caretLocation
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTextTooLong: () => showToast(ToastMessageBodyTooLong),
|
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
|
// Cancel any link still pending, even if it didn't make it into the message
|
||||||
this.resetLinkPreview();
|
resetLinkPreview();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -2920,7 +2901,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
body: message,
|
body: message,
|
||||||
attachments,
|
attachments,
|
||||||
quote: this.quote,
|
quote: this.quote,
|
||||||
preview: this.getLinkPreviewForSend(message),
|
preview: getLinkPreviewForSend(message),
|
||||||
mentions,
|
mentions,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -2930,7 +2911,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
this.compositionApi.current?.reset();
|
this.compositionApi.current?.reset();
|
||||||
model.setMarkedUnread(false);
|
model.setMarkedUnread(false);
|
||||||
this.setQuoteMessage(null);
|
this.setQuoteMessage(null);
|
||||||
this.resetLinkPreview();
|
resetLinkPreview();
|
||||||
this.clearAttachments();
|
this.clearAttachments();
|
||||||
window.reduxActions.composer.resetComposer();
|
window.reduxActions.composer.resetComposer();
|
||||||
},
|
},
|
||||||
|
@ -2953,7 +2934,15 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
): void {
|
): void {
|
||||||
this.maybeBumpTyping(messageText);
|
this.maybeBumpTyping(messageText);
|
||||||
this.debouncedSaveDraft(messageText, bodyRanges);
|
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(
|
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
|
// Called whenever the user changes the message composition field. But only
|
||||||
// fires if there's content in the message field after the change.
|
// fires if there's content in the message field after the change.
|
||||||
maybeBumpTyping(messageText: string): void {
|
maybeBumpTyping(messageText: string): void {
|
||||||
|
|
26
yarn.lock
|
@ -13528,6 +13528,15 @@ react-syntax-highlighter@^15.4.5:
|
||||||
prismjs "^1.27.0"
|
prismjs "^1.27.0"
|
||||||
refractor "^3.6.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:
|
react-transition-group@^4.3.0:
|
||||||
version "4.4.2"
|
version "4.4.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470"
|
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"
|
punycode "1.3.2"
|
||||||
querystring "0.2.0"
|
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:
|
use@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/use/-/use-3.1.0.tgz#14716bf03fdfefd03040aef58d8b4b85f3a7c544"
|
resolved "https://registry.yarnpkg.com/use/-/use-3.1.0.tgz#14716bf03fdfefd03040aef58d8b4b85f3a7c544"
|
||||||
|
|